From bf3a801245a0be1d9001ee106e48cbf8b4bc73df Mon Sep 17 00:00:00 2001
From: TheSiahxyz <164138827+TheSiahxyz@users.noreply.github.com>
Date: Wed, 3 Dec 2025 18:39:08 +0900
Subject: (임수민) 페이지 타이틀과 메뉴명 일치 작업
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 app/[lng]/evcp/(evcp)/(system)/menu-list/page.tsx | 24 ++++-------------------
 1 file changed, 4 insertions(+), 20 deletions(-)

(limited to 'app/[lng]/evcp/(evcp)/(system)/menu-list/page.tsx')

diff --git a/app/[lng]/evcp/(evcp)/(system)/menu-list/page.tsx b/app/[lng]/evcp/(evcp)/(system)/menu-list/page.tsx
index 5a1f71a5..2cff434e 100644
--- a/app/[lng]/evcp/(evcp)/(system)/menu-list/page.tsx
+++ b/app/[lng]/evcp/(evcp)/(system)/menu-list/page.tsx
@@ -1,9 +1,7 @@
 // app/evcp/menu-list/page.tsx
 
 import { Suspense } from "react";
-import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
-import { Button } from "@/components/ui/button";
-import { RefreshCw, Settings } from "lucide-react";
+import { Card, CardContent } from "@/components/ui/card";
 import { getActiveUsers, getMenuAssignments } from "@/lib/menu-list/servcie";
 import { InitializeButton } from "@/lib/menu-list/table/initialize-button";
 import { MenuListTable } from "@/lib/menu-list/table/menu-list-table";
@@ -42,7 +40,7 @@ export default async function MenuListPage({ params }: MenuListPageProps) {
           <div>
             <div className="flex items-center gap-2">
               <h2 className="text-2xl font-bold tracking-tight">
-                메뉴 관리
+                {t('menu.information_system.menu_list')}
               </h2>
               <InformationButton pagePath="evcp/menu-list" />
             </div>
@@ -61,21 +59,7 @@ export default async function MenuListPage({ params }: MenuListPageProps) {
         }
       >
         <Card>
-          <CardHeader>
-            <CardTitle className="flex items-center gap-2">
-              <Settings className="h-5 w-5" />
-              메뉴 리스트
-            </CardTitle>
-            <CardDescription>
-              시스템의 모든 메뉴와 담당자 정보를 확인할 수 있습니다.
-              {menusResult.data?.length > 0 && (
-                <span className="ml-2 text-sm">
-                  총 {menusResult.data.length}개의 메뉴
-                </span>
-              )}
-            </CardDescription>
-          </CardHeader>
-          <CardContent>
+          <CardContent className="pt-6">
             <Suspense fallback={<div className="text-center py-8">로딩 중...</div>}>
               <MenuListTable
                 initialMenus={translatedMenus}
@@ -88,4 +72,4 @@ export default async function MenuListPage({ params }: MenuListPageProps) {
     </Shell>
 
   );
-}
\ No newline at end of file
+}
-- 
cgit v1.2.3


From 04ed774ff60a83c00711d4e8615cb4122954dba5 Mon Sep 17 00:00:00 2001
From: joonhoekim <26rote@gmail.com>
Date: Thu, 4 Dec 2025 19:46:55 +0900
Subject: (김준회) 메뉴 관리기능 초안 개발 (시딩 필요)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 app/[lng]/evcp/(evcp)/(system)/menu-list/page.tsx |  79 +--
 app/[lng]/evcp/(evcp)/layout.tsx                  |   7 +-
 app/[lng]/partners/(partners)/layout.tsx          |   4 +-
 components/layout/DynamicMenuRender.tsx           | 146 ++++++
 components/layout/HeaderV2.tsx                    | 295 +++++++++++
 components/layout/MobileMenuV2.tsx                | 160 ++++++
 db/schema/index.ts                                |   4 +
 db/schema/menu-v2.ts                              |  88 ++++
 db/seeds/menu-v2-seed.js                          | 231 +++++++++
 db/seeds/menu-v2-seed.ts                          | 145 ++++++
 hooks/use-visible-menu-tree.ts                    |  49 ++
 lib/information/service.ts                        |  32 +-
 lib/menu-v2/components/add-node-dialog.tsx        | 186 +++++++
 lib/menu-v2/components/domain-tabs.tsx            |  25 +
 lib/menu-v2/components/edit-node-dialog.tsx       | 215 ++++++++
 lib/menu-v2/components/menu-tree-manager.tsx      | 364 +++++++++++++
 lib/menu-v2/components/menu-tree.tsx              | 282 ++++++++++
 lib/menu-v2/components/move-to-dialog.tsx         |  87 ++++
 lib/menu-v2/components/unassigned-menus-panel.tsx | 178 +++++++
 lib/menu-v2/permission-service.ts                 | 186 +++++++
 lib/menu-v2/service.ts                            | 605 ++++++++++++++++++++++
 lib/menu-v2/types.ts                              | 103 ++++
 22 files changed, 3384 insertions(+), 87 deletions(-)
 create mode 100644 components/layout/DynamicMenuRender.tsx
 create mode 100644 components/layout/HeaderV2.tsx
 create mode 100644 components/layout/MobileMenuV2.tsx
 create mode 100644 db/schema/menu-v2.ts
 create mode 100644 db/seeds/menu-v2-seed.js
 create mode 100644 db/seeds/menu-v2-seed.ts
 create mode 100644 hooks/use-visible-menu-tree.ts
 create mode 100644 lib/menu-v2/components/add-node-dialog.tsx
 create mode 100644 lib/menu-v2/components/domain-tabs.tsx
 create mode 100644 lib/menu-v2/components/edit-node-dialog.tsx
 create mode 100644 lib/menu-v2/components/menu-tree-manager.tsx
 create mode 100644 lib/menu-v2/components/menu-tree.tsx
 create mode 100644 lib/menu-v2/components/move-to-dialog.tsx
 create mode 100644 lib/menu-v2/components/unassigned-menus-panel.tsx
 create mode 100644 lib/menu-v2/permission-service.ts
 create mode 100644 lib/menu-v2/service.ts
 create mode 100644 lib/menu-v2/types.ts

(limited to 'app/[lng]/evcp/(evcp)/(system)/menu-list/page.tsx')

diff --git a/app/[lng]/evcp/(evcp)/(system)/menu-list/page.tsx b/app/[lng]/evcp/(evcp)/(system)/menu-list/page.tsx
index 2cff434e..79923397 100644
--- a/app/[lng]/evcp/(evcp)/(system)/menu-list/page.tsx
+++ b/app/[lng]/evcp/(evcp)/(system)/menu-list/page.tsx
@@ -1,75 +1,20 @@
-// app/evcp/menu-list/page.tsx
+import { MenuTreeManager } from "@/lib/menu-v2/components/menu-tree-manager";
 
-import { Suspense } from "react";
-import { Card, CardContent } from "@/components/ui/card";
-import { getActiveUsers, getMenuAssignments } from "@/lib/menu-list/servcie";
-import { InitializeButton } from "@/lib/menu-list/table/initialize-button";
-import { MenuListTable } from "@/lib/menu-list/table/menu-list-table";
-import { Shell } from "@/components/shell"
-import * as React from "react"
-import { InformationButton } from "@/components/information/information-button";
-import { useTranslation } from "@/i18n";
-interface MenuListPageProps {
-  params: Promise<{ lng: string }>
+interface PageProps {
+  params: Promise<{ lng: string }>;
 }
 
-export default async function MenuListPage({ params }: MenuListPageProps) {
-  const { lng } = await params
-  const { t } = await useTranslation(lng, 'menu')
+export default async function MenuV2Page({ params }: PageProps) {
+  const { lng } = await params;
   
-  // 초기 데이터 로드
-  const [menusResult, usersResult] = await Promise.all([
-    getMenuAssignments(),
-    getActiveUsers()
-  ]);
-
-  // 서버사이드에서 번역된 메뉴 데이터 생성
-  const translatedMenus = menusResult.data?.map(menu => ({
-    ...menu,
-    sectionTitle: menu.sectionTitle || "",
-    translatedMenuTitle: t(menu.menuTitle || ""),
-    translatedSectionTitle: t(menu.sectionTitle || ""),
-    translatedMenuGroup: menu.menuGroup ? t(menu.menuGroup) : null,
-    translatedMenuDescription: menu.menuDescription ? t(menu.menuDescription) : null
-  })) || [];
-
   return (
-    <Shell className="gap-2">
-      <div className="flex items-center justify-between space-y-2">
-        <div className="flex items-center justify-between space-y-2">
-          <div>
-            <div className="flex items-center gap-2">
-              <h2 className="text-2xl font-bold tracking-tight">
-                {t('menu.information_system.menu_list')}
-              </h2>
-              <InformationButton pagePath="evcp/menu-list" />
-            </div>
-            {/* <p className="text-muted-foreground">
-              각 메뉴별로 담당자를 지정하고 관리할 수 있습니다.
-            </p> */}
-          </div>
-        </div>
-
+    <div className="container mx-auto py-6 space-y-6">
+      <div>
+        <h1 className="text-2xl font-bold tracking-tight">Menu Management</h1>
       </div>
-
-
-      <React.Suspense
-        fallback={
-         ""
-        }
-      >
-        <Card>
-          <CardContent className="pt-6">
-            <Suspense fallback={<div className="text-center py-8">로딩 중...</div>}>
-              <MenuListTable
-                initialMenus={translatedMenus}
-                initialUsers={usersResult.data || []}
-              />
-            </Suspense>
-          </CardContent>
-        </Card>
-      </React.Suspense>
-    </Shell>
-
+      
+      <MenuTreeManager initialDomain="evcp" />
+    </div>
   );
 }
+
diff --git a/app/[lng]/evcp/(evcp)/layout.tsx b/app/[lng]/evcp/(evcp)/layout.tsx
index c5e75a4c..093d9301 100644
--- a/app/[lng]/evcp/(evcp)/layout.tsx
+++ b/app/[lng]/evcp/(evcp)/layout.tsx
@@ -1,5 +1,5 @@
 import { ReactNode } from 'react';
-import { Header } from '@/components/layout/Header';
+import { HeaderV2 } from '@/components/layout/HeaderV2';
 import { SiteFooter } from '@/components/layout/Footer';
 import { getServerSession } from "next-auth";
 import { authOptions } from "@/app/api/auth/[...nextauth]/route";
@@ -20,7 +20,8 @@ export default async function EvcpLayout({ children }: { children: ReactNode })
         try {
             const result = await verifyNonsapPermission(
                 parseInt(session.user.id), 
-                ['SEARCH']
+                // ['SEARCH']
+                [] // 아무런 실제 권한이 없어도, 등록된 상태라면 화면에 'SEARCH' 권한이 있는것처럼 동작하게 해달라고 함. (김희은 프로)
             );
             isAuthorized = result.authorized;
             authMessage = result.message || "";
@@ -36,7 +37,7 @@ export default async function EvcpLayout({ children }: { children: ReactNode })
     return (
         <div className="relative flex min-h-svh flex-col bg-background">
         {/* <div className="relative flex min-h-svh flex-col bg-slate-100 "> */}
-            <Header />
+            <HeaderV2 />
             {!skipPermissionCheck && (
                 <PermissionChecker authorized={isAuthorized} message={authMessage} />
             )}
diff --git a/app/[lng]/partners/(partners)/layout.tsx b/app/[lng]/partners/(partners)/layout.tsx
index 9dc39f7b..51a30028 100644
--- a/app/[lng]/partners/(partners)/layout.tsx
+++ b/app/[lng]/partners/(partners)/layout.tsx
@@ -1,11 +1,11 @@
 import { ReactNode } from 'react';
-import { Header } from '@/components/layout/Header';
+import { HeaderV2 } from '@/components/layout/HeaderV2';
 import { SiteFooter } from '@/components/layout/Footer';
 
 export default function EvcpLayout({ children }: { children: ReactNode }) {
     return (
         <div className="relative flex min-h-svh flex-col bg-background">
-            <Header />
+            <HeaderV2 />
             <main className="flex flex-1 flex-col">
                 <div className='container-wrapper'>
                         {children}
diff --git a/components/layout/DynamicMenuRender.tsx b/components/layout/DynamicMenuRender.tsx
new file mode 100644
index 00000000..f94223ae
--- /dev/null
+++ b/components/layout/DynamicMenuRender.tsx
@@ -0,0 +1,146 @@
+"use client";
+
+import Link from "next/link";
+import { cn } from "@/lib/utils";
+import { NavigationMenuLink } from "@/components/ui/navigation-menu";
+import type { MenuTreeNode } from "@/lib/menu-v2/types";
+
+interface DynamicMenuRenderProps {
+  groups: MenuTreeNode[] | undefined;
+  lng: string;
+  getTitle: (node: MenuTreeNode) => string;
+  getDescription: (node: MenuTreeNode) => string | null;
+  onItemClick?: () => void;
+}
+
+export default function DynamicMenuRender({
+  groups,
+  lng,
+  getTitle,
+  getDescription,
+  onItemClick,
+}: DynamicMenuRenderProps) {
+  if (!groups || groups.length === 0) {
+    return (
+      <div className="p-4 text-sm text-muted-foreground">
+        메뉴가 없습니다.
+      </div>
+    );
+  }
+
+  // 그룹별로 메뉴 분류
+  const groupedMenus = new Map<string, MenuTreeNode[]>();
+  const ungroupedMenus: MenuTreeNode[] = [];
+
+  for (const item of groups) {
+    if (item.nodeType === "group") {
+      // 그룹인 경우, 그룹의 children을 해당 그룹에 추가
+      const groupTitle = getTitle(item);
+      if (!groupedMenus.has(groupTitle)) {
+        groupedMenus.set(groupTitle, []);
+      }
+      if (item.children) {
+        groupedMenus.get(groupTitle)!.push(...item.children);
+      }
+    } else if (item.nodeType === "menu") {
+      // 직접 메뉴인 경우 (그룹 없이 직접 메뉴그룹에 속한 경우)
+      ungroupedMenus.push(item);
+    }
+  }
+
+  // 그룹이 없고 메뉴만 있는 경우 - 단순 그리드 렌더링
+  if (groupedMenus.size === 0 && ungroupedMenus.length > 0) {
+    return (
+      <ul className="grid w-[400px] gap-3 p-4 md:w-[500px] md:grid-cols-2 lg:w-[600px]">
+        {ungroupedMenus.map((menu) => (
+          <MenuListItem
+            key={menu.id}
+            href={`/${lng}${menu.menuPath}`}
+            title={getTitle(menu)}
+            onClick={onItemClick}
+          >
+            {getDescription(menu)}
+          </MenuListItem>
+        ))}
+      </ul>
+    );
+  }
+
+  // 그룹별 렌더링 - 가로 스크롤 지원
+  // 컨텐츠가 85vw를 초과할 때만 스크롤 발생
+  return (
+    <div className="p-4 max-w-[85vw]">
+      <div className="flex gap-6 overflow-x-auto">
+        {/* 그룹화되지 않은 메뉴 (있는 경우) */}
+        {ungroupedMenus.length > 0 && (
+          <div className="w-[200px] flex-shrink-0">
+            <ul className="space-y-2">
+              {ungroupedMenus.map((menu) => (
+                <MenuListItem
+                  key={menu.id}
+                  href={`/${lng}${menu.menuPath}`}
+                  title={getTitle(menu)}
+                  onClick={onItemClick}
+                >
+                  {getDescription(menu)}
+                </MenuListItem>
+              ))}
+            </ul>
+          </div>
+        )}
+
+        {/* 그룹별 메뉴 - 순서대로 가로 배치 */}
+        {Array.from(groupedMenus.entries()).map(([groupTitle, menus]) => (
+          <div key={groupTitle} className="w-[200px] flex-shrink-0">
+            <h4 className="mb-2 text-sm font-semibold text-muted-foreground whitespace-nowrap">
+              {groupTitle}
+            </h4>
+            <ul className="space-y-2">
+              {menus.map((menu) => (
+                <MenuListItem
+                  key={menu.id}
+                  href={`/${lng}${menu.menuPath}`}
+                  title={getTitle(menu)}
+                  onClick={onItemClick}
+                >
+                  {getDescription(menu)}
+                </MenuListItem>
+              ))}
+            </ul>
+          </div>
+        ))}
+      </div>
+    </div>
+  );
+}
+
+interface MenuListItemProps {
+  href: string;
+  title: string;
+  children?: React.ReactNode;
+  onClick?: () => void;
+}
+
+function MenuListItem({ href, title, children, onClick }: MenuListItemProps) {
+  return (
+    <li>
+      <NavigationMenuLink asChild>
+        <Link
+          href={href}
+          onClick={onClick}
+          className={cn(
+            "block select-none space-y-1 rounded-md p-2 leading-none no-underline outline-none transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground"
+          )}
+        >
+          <div className="text-sm font-medium leading-none">{title}</div>
+          {children && (
+            <p className="line-clamp-2 text-xs leading-snug text-muted-foreground">
+              {children}
+            </p>
+          )}
+        </Link>
+      </NavigationMenuLink>
+    </li>
+  );
+}
+
diff --git a/components/layout/HeaderV2.tsx b/components/layout/HeaderV2.tsx
new file mode 100644
index 00000000..88d50cc5
--- /dev/null
+++ b/components/layout/HeaderV2.tsx
@@ -0,0 +1,295 @@
+"use client";
+
+import * as React from "react";
+import Link from "next/link";
+import { Button } from "@/components/ui/button";
+import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar";
+import {
+  DropdownMenu,
+  DropdownMenuContent,
+  DropdownMenuItem,
+  DropdownMenuLabel,
+  DropdownMenuSeparator,
+  DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+import {
+  NavigationMenu,
+  NavigationMenuContent,
+  NavigationMenuItem,
+  NavigationMenuLink,
+  NavigationMenuList,
+  NavigationMenuTrigger,
+  navigationMenuTriggerStyle,
+} from "@/components/ui/navigation-menu";
+import { SearchIcon, Loader2 } from "lucide-react";
+import { useParams, usePathname } from "next/navigation";
+import { cn } from "@/lib/utils";
+import Image from "next/image";
+import { useSession } from "next-auth/react";
+import { customSignOut } from "@/lib/auth/custom-signout";
+import DynamicMenuRender from "./DynamicMenuRender";
+import { MobileMenuV2 } from "./MobileMenuV2";
+import { CommandMenu } from "./command-menu";
+import { NotificationDropdown } from "./NotificationDropdown";
+import { useVisibleMenuTree } from "@/hooks/use-visible-menu-tree";
+import { useTranslation } from "@/i18n/client";
+import type { MenuDomain, MenuTreeNode } from "@/lib/menu-v2/types";
+
+// 도메인별 브랜드명
+const domainBrandingKeys: Record<MenuDomain, string> = {
+  evcp: "branding.evcp_main",
+  partners: "branding.evcp_partners",
+};
+
+export function HeaderV2() {
+  const params = useParams();
+  const lng = (params?.lng as string) || "ko";
+  const pathname = usePathname();
+  const { data: session } = useSession();
+  const { t } = useTranslation(lng, "menu");
+
+  // 현재 도메인 결정
+  const domain: MenuDomain = pathname?.includes("/partners") ? "partners" : "evcp";
+
+  // 메뉴 데이터 로드 (tree에 드롭다운과 단일 링크가 모두 포함됨)
+  const { tree, isLoading } = useVisibleMenuTree(domain);
+
+  const userName = session?.user?.name || "";
+  const initials = userName
+    .split(" ")
+    .map((word) => word[0]?.toUpperCase())
+    .join("");
+
+  const [isMobileMenuOpen, setIsMobileMenuOpen] = React.useState(false);
+  const [openMenuKey, setOpenMenuKey] = React.useState<string>("");
+
+  const toggleMobileMenu = () => {
+    setIsMobileMenuOpen(!isMobileMenuOpen);
+  };
+
+  const toggleMenu = React.useCallback((menuKey: string) => {
+    setOpenMenuKey((prev) => (prev === menuKey ? "" : menuKey));
+  }, []);
+
+  // 페이지 이동 시 메뉴 닫기
+  React.useEffect(() => {
+    setOpenMenuKey("");
+  }, [pathname]);
+
+  // 브랜딩 및 경로 설정
+  const brandNameKey = domainBrandingKeys[domain];
+  const logoHref = `/${lng}/${domain}`;
+  const basePath = `/${lng}/${domain}`;
+
+  // 다국어 텍스트 선택
+  const getTitle = (node: MenuTreeNode) =>
+    lng === "ko" ? node.titleKo : node.titleEn || node.titleKo;
+
+  const getDescription = (node: MenuTreeNode) =>
+    lng === "ko"
+      ? node.descriptionKo
+      : node.descriptionEn || node.descriptionKo;
+
+  // 메뉴 노드가 드롭다운(자식 있음)인지 단일 링크인지 판단
+  const isDropdownMenu = (node: MenuTreeNode) => 
+    node.nodeType === 'menu_group' && node.children && node.children.length > 0;
+
+  return (
+    <>
+      <header className="border-grid sticky top-0 z-40 w-full border-b bg-slate-100 backdrop-blur supports-[backdrop-filter]:bg-background/60">
+        <div className="container-wrapper">
+          <div className="container flex h-14 items-center">
+            {/* 햄버거 메뉴 버튼 (모바일) */}
+            <Button
+              onClick={toggleMobileMenu}
+              variant="ghost"
+              className="-ml-2 mr-2 h-8 w-8 px-0 text-base hover:bg-transparent focus-visible:bg-transparent focus-visible:ring-0 focus-visible:ring-offset-0 md:hidden"
+            >
+              <svg
+                xmlns="http://www.w3.org/2000/svg"
+                fill="none"
+                viewBox="0 0 24 24"
+                strokeWidth="1.5"
+                stroke="currentColor"
+                className="!size-6"
+              >
+                <path
+                  strokeLinecap="round"
+                  strokeLinejoin="round"
+                  d="M3.75 9h16.5m-16.5 6.75h16.5"
+                />
+              </svg>
+              <span className="sr-only">{t("menu.toggle_menu")}</span>
+            </Button>
+
+            {/* 로고 영역 */}
+            <div className="mr-4 flex-shrink-0 flex items-center gap-2 lg:mr-6">
+              <Link href={logoHref} className="flex items-center gap-2">
+                <Image
+                  className="dark:invert"
+                  src="/images/vercel.svg"
+                  alt="EVCP Logo"
+                  width={20}
+                  height={20}
+                />
+                <span className="hidden font-bold lg:inline-block">
+                  {t(brandNameKey)}
+                </span>
+              </Link>
+            </div>
+
+            {/* 네비게이션 메뉴 */}
+            <div className="hidden md:block flex-1 min-w-0">
+              {isLoading ? (
+                <div className="flex items-center justify-center h-10">
+                  <Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
+                </div>
+              ) : (
+                <NavigationMenu
+                  className="relative z-50"
+                  value={openMenuKey}
+                  onValueChange={setOpenMenuKey}
+                >
+                  <div className="w-full overflow-x-auto pb-1">
+                    <NavigationMenuList className="flex-nowrap w-max">
+                      {tree.map((node) => {
+                        // 드롭다운 메뉴 (menu_group with children)
+                        if (isDropdownMenu(node)) {
+                          return (
+                            <NavigationMenuItem
+                              key={node.id}
+                              value={String(node.id)}
+                            >
+                              <NavigationMenuTrigger
+                                className="px-2 xl:px-3 text-sm whitespace-nowrap"
+                                onClick={(e) => {
+                                  e.preventDefault();
+                                  e.stopPropagation();
+                                  toggleMenu(String(node.id));
+                                }}
+                                onPointerEnter={(e) => e.preventDefault()}
+                                onPointerMove={(e) => e.preventDefault()}
+                                onPointerLeave={(e) => e.preventDefault()}
+                              >
+                                {getTitle(node)}
+                              </NavigationMenuTrigger>
+
+                              <NavigationMenuContent
+                                className="max-h-[80vh] overflow-y-auto overflow-x-hidden"
+                                onPointerEnter={(e) => e.preventDefault()}
+                                onPointerLeave={(e) => e.preventDefault()}
+                                forceMount={
+                                  openMenuKey === String(node.id)
+                                    ? true
+                                    : undefined
+                                }
+                              >
+                                <DynamicMenuRender
+                                  groups={node.children}
+                                  lng={lng}
+                                  getTitle={getTitle}
+                                  getDescription={getDescription}
+                                  onItemClick={() => setOpenMenuKey("")}
+                                />
+                              </NavigationMenuContent>
+                            </NavigationMenuItem>
+                          );
+                        }
+
+                        // 단일 링크 메뉴 (최상위 menu)
+                        if (node.nodeType === 'menu' && node.menuPath) {
+                          return (
+                            <NavigationMenuItem key={node.id}>
+                              <Link
+                                href={`/${lng}${node.menuPath}`}
+                                legacyBehavior
+                                passHref
+                              >
+                                <NavigationMenuLink
+                                  className={cn(
+                                    navigationMenuTriggerStyle(),
+                                    "px-2 xl:px-3 text-sm whitespace-nowrap"
+                                  )}
+                                  onPointerEnter={(e) => e.preventDefault()}
+                                  onPointerLeave={(e) => e.preventDefault()}
+                                >
+                                  {getTitle(node)}
+                                </NavigationMenuLink>
+                              </Link>
+                            </NavigationMenuItem>
+                          );
+                        }
+
+                        return null;
+                      })}
+                    </NavigationMenuList>
+                  </div>
+                </NavigationMenu>
+              )}
+            </div>
+
+            {/* 우측 영역 */}
+            <div className="ml-auto flex flex-shrink-0 items-center space-x-2">
+              {/* 데스크탑에서는 CommandMenu, 모바일에서는 검색 아이콘만 */}
+              <div className="hidden md:block md:w-auto">
+                <CommandMenu />
+              </div>
+              <Button
+                variant="ghost"
+                size="icon"
+                className="md:hidden"
+                aria-label={t("common.search")}
+              >
+                <SearchIcon className="h-5 w-5" />
+              </Button>
+
+              {/* 알림 버튼 */}
+              <NotificationDropdown />
+
+              {/* 사용자 메뉴 */}
+              <DropdownMenu>
+                <DropdownMenuTrigger asChild>
+                  <Avatar className="cursor-pointer h-8 w-8">
+                    <AvatarImage
+                      src={`${session?.user?.image}` || "/user-avatar.jpg"}
+                      alt="User Avatar"
+                    />
+                    <AvatarFallback>{initials || "?"}</AvatarFallback>
+                  </Avatar>
+                </DropdownMenuTrigger>
+                <DropdownMenuContent className="w-48" align="end">
+                  <DropdownMenuLabel>{t("user.my_account")}</DropdownMenuLabel>
+                  <DropdownMenuSeparator />
+                  <DropdownMenuItem asChild>
+                    <Link href={`${basePath}/settings`}>{t("user.settings")}</Link>
+                  </DropdownMenuItem>
+                  <DropdownMenuSeparator />
+                  <DropdownMenuItem
+                    onSelect={() =>
+                      customSignOut({
+                        callbackUrl: `${window.location.origin}${basePath}`,
+                      })
+                    }
+                  >
+                    {t("user.logout")}
+                  </DropdownMenuItem>
+                </DropdownMenuContent>
+              </DropdownMenu>
+            </div>
+          </div>
+        </div>
+
+        {/* 모바일 메뉴 */}
+        {isMobileMenuOpen && (
+          <MobileMenuV2
+            lng={lng}
+            onClose={toggleMobileMenu}
+            tree={tree}
+            getTitle={getTitle}
+            getDescription={getDescription}
+          />
+        )}
+      </header>
+    </>
+  );
+}
diff --git a/components/layout/MobileMenuV2.tsx b/components/layout/MobileMenuV2.tsx
new file mode 100644
index 00000000..c83ba779
--- /dev/null
+++ b/components/layout/MobileMenuV2.tsx
@@ -0,0 +1,160 @@
+"use client";
+
+import * as React from "react";
+import Link from "next/link";
+import { cn } from "@/lib/utils";
+import { Button } from "@/components/ui/button";
+import { ScrollArea } from "@/components/ui/scroll-area";
+import { X, ChevronDown, ChevronRight } from "lucide-react";
+import type { MenuTreeNode } from "@/lib/menu-v2/types";
+
+interface MobileMenuV2Props {
+  lng: string;
+  onClose: () => void;
+  tree: MenuTreeNode[];
+  getTitle: (node: MenuTreeNode) => string;
+  getDescription: (node: MenuTreeNode) => string | null;
+}
+
+export function MobileMenuV2({
+  lng,
+  onClose,
+  tree,
+  getTitle,
+  getDescription,
+}: MobileMenuV2Props) {
+  const [expandedGroups, setExpandedGroups] = React.useState<Set<number>>(
+    new Set()
+  );
+
+  const toggleGroup = (groupId: number) => {
+    setExpandedGroups((prev) => {
+      const next = new Set(prev);
+      if (next.has(groupId)) {
+        next.delete(groupId);
+      } else {
+        next.add(groupId);
+      }
+      return next;
+    });
+  };
+
+  // 드롭다운 메뉴인지 판단
+  const isDropdownMenu = (node: MenuTreeNode) => 
+    node.nodeType === 'menu_group' && node.children && node.children.length > 0;
+
+  return (
+    <div className="fixed inset-0 z-50 bg-background md:hidden">
+      {/* 헤더 */}
+      <div className="flex items-center justify-between px-4 h-14 border-b">
+        <span className="font-semibold">메뉴</span>
+        <Button variant="ghost" size="icon" onClick={onClose}>
+          <X className="h-5 w-5" />
+          <span className="sr-only">닫기</span>
+        </Button>
+      </div>
+
+      {/* 스크롤 영역 */}
+      <ScrollArea className="h-[calc(100vh-56px)]">
+        <div className="px-4 py-4 space-y-2">
+          {tree.map((node) => {
+            // 드롭다운 메뉴 (menu_group with children)
+            if (isDropdownMenu(node)) {
+              return (
+                <div key={node.id} className="space-y-2">
+                  {/* 메뉴그룹 헤더 */}
+                  <button
+                    onClick={() => toggleGroup(node.id)}
+                    className="flex items-center justify-between w-full py-2 text-left font-semibold"
+                  >
+                    <span>{getTitle(node)}</span>
+                    {expandedGroups.has(node.id) ? (
+                      <ChevronDown className="h-4 w-4" />
+                    ) : (
+                      <ChevronRight className="h-4 w-4" />
+                    )}
+                  </button>
+
+                  {/* 하위 메뉴 */}
+                  {expandedGroups.has(node.id) && (
+                    <div className="pl-4 space-y-1">
+                      {node.children?.map((item) => {
+                        if (item.nodeType === "group") {
+                          // 그룹인 경우
+                          return (
+                            <div key={item.id} className="space-y-1">
+                              <div className="text-xs text-muted-foreground font-medium py-1">
+                                {getTitle(item)}
+                              </div>
+                              <div className="pl-2 space-y-1">
+                                {item.children?.map((menu) => (
+                                  <MobileMenuLink
+                                    key={menu.id}
+                                    href={`/${lng}${menu.menuPath}`}
+                                    title={getTitle(menu)}
+                                    onClick={onClose}
+                                  />
+                                ))}
+                              </div>
+                            </div>
+                          );
+                        } else if (item.nodeType === "menu") {
+                          // 직접 메뉴인 경우
+                          return (
+                            <MobileMenuLink
+                              key={item.id}
+                              href={`/${lng}${item.menuPath}`}
+                              title={getTitle(item)}
+                              onClick={onClose}
+                            />
+                          );
+                        }
+                        return null;
+                      })}
+                    </div>
+                  )}
+                </div>
+              );
+            }
+
+            // 단일 링크 메뉴 (최상위 menu)
+            if (node.nodeType === 'menu' && node.menuPath) {
+              return (
+                <MobileMenuLink
+                  key={node.id}
+                  href={`/${lng}${node.menuPath}`}
+                  title={getTitle(node)}
+                  onClick={onClose}
+                />
+              );
+            }
+
+            return null;
+          })}
+        </div>
+      </ScrollArea>
+    </div>
+  );
+}
+
+interface MobileMenuLinkProps {
+  href: string;
+  title: string;
+  onClick: () => void;
+}
+
+function MobileMenuLink({ href, title, onClick }: MobileMenuLinkProps) {
+  return (
+    <Link
+      href={href}
+      onClick={onClick}
+      className={cn(
+        "block py-2 px-2 rounded-md text-sm",
+        "hover:bg-accent hover:text-accent-foreground",
+        "transition-colors"
+      )}
+    >
+      {title}
+    </Link>
+  );
+}
diff --git a/db/schema/index.ts b/db/schema/index.ts
index 6463e0ec..022431cc 100644
--- a/db/schema/index.ts
+++ b/db/schema/index.ts
@@ -29,7 +29,11 @@ export * from './evaluation';
 export * from './evaluationTarget';
 export * from './evaluationCriteria';
 export * from './projectGtc';
+// 기존 menu 스키마 (deprecated - menu-v2로 대체됨)
 export * from './menu';
+
+// 새로운 메뉴 트리 스키마 (v2)
+export * from './menu-v2';
 export * from './information';
 export * from './qna';
 export * from './notice';
diff --git a/db/schema/menu-v2.ts b/db/schema/menu-v2.ts
new file mode 100644
index 00000000..2d0282fa
--- /dev/null
+++ b/db/schema/menu-v2.ts
@@ -0,0 +1,88 @@
+// db/schema/menu-v2.ts
+import { pgTable, pgEnum, integer, varchar, text, timestamp, boolean, index, uniqueIndex } from "drizzle-orm/pg-core";
+import { relations } from "drizzle-orm";
+import { users } from "./users";
+
+export const menuTreeNodeTypeEnum = pgEnum('menu_tree_node_type', [
+  'menu_group',  // 메뉴그룹 (1단계) - 헤더에 표시되는 드롭다운 트리거
+  'group',       // 그룹 (2단계) - 드롭다운 내 구분 영역
+  'menu',        // 메뉴 (3단계) - 드롭다운 내 링크
+  'additional'   // 추가 메뉴 - 최상위 단일 링크 (Dashboard, QNA, FAQ 등)
+]);
+
+export const menuDomainEnum = pgEnum('menu_domain', [
+  'evcp',     // 내부 사용자용
+  'partners'  // 협력업체용
+]);
+
+export const menuTreeNodes = pgTable("menu_tree_nodes", {
+  id: integer("id").primaryKey().generatedAlwaysAsIdentity(),
+  
+  // 도메인 구분
+  domain: menuDomainEnum("domain").notNull(),
+  
+  // 트리 구조
+  parentId: integer("parent_id").references((): any => menuTreeNodes.id, { onDelete: "cascade" }),
+  nodeType: menuTreeNodeTypeEnum("node_type").notNull(),
+  sortOrder: integer("sort_order").notNull().default(0),
+  
+  // 다국어 텍스트 (DB 직접 관리)
+  titleKo: varchar("title_ko", { length: 255 }).notNull(),
+  titleEn: varchar("title_en", { length: 255 }),
+  descriptionKo: text("description_ko"),
+  descriptionEn: text("description_en"),
+  
+  // 메뉴 전용 필드 (nodeType === 'menu' 또는 'additional'일 때)
+  menuPath: varchar("menu_path", { length: 255 }),  // href 값 (예: /evcp/projects)
+  icon: varchar("icon", { length: 100 }),
+  
+  // 권한 연동
+  // evcp: Oracle DB SCR_ID 참조
+  // partners: 자체 권한 시스템 (TODO)
+  scrId: varchar("scr_id", { length: 100 }),
+  
+  // 상태
+  isActive: boolean("is_active").default(true).notNull(),
+  
+  // 담당자 (evcp 전용)
+  manager1Id: integer("manager1_id").references(() => users.id, { onDelete: "set null" }),
+  manager2Id: integer("manager2_id").references(() => users.id, { onDelete: "set null" }),
+  
+  createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
+  updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
+}, (table) => ({
+  domainIdx: index("menu_tree_domain_idx").on(table.domain),
+  parentIdx: index("menu_tree_parent_idx").on(table.parentId),
+  sortOrderIdx: index("menu_tree_sort_order_idx").on(table.sortOrder),
+  menuPathUnique: uniqueIndex("menu_tree_path_unique_idx").on(table.menuPath),
+  scrIdIdx: index("menu_tree_scr_id_idx").on(table.scrId),
+}));
+
+// Relations 정의
+export const menuTreeNodesRelations = relations(menuTreeNodes, ({ one, many }) => ({
+  parent: one(menuTreeNodes, {
+    fields: [menuTreeNodes.parentId],
+    references: [menuTreeNodes.id],
+    relationName: "parentChild",
+  }),
+  children: many(menuTreeNodes, {
+    relationName: "parentChild",
+  }),
+  manager1: one(users, {
+    fields: [menuTreeNodes.manager1Id],
+    references: [users.id],
+    relationName: "menuManager1",
+  }),
+  manager2: one(users, {
+    fields: [menuTreeNodes.manager2Id],
+    references: [users.id],
+    relationName: "menuManager2",
+  }),
+}));
+
+// Type exports
+export type MenuTreeNode = typeof menuTreeNodes.$inferSelect;
+export type NewMenuTreeNode = typeof menuTreeNodes.$inferInsert;
+export type NodeType = (typeof menuTreeNodeTypeEnum.enumValues)[number];
+export type MenuDomain = (typeof menuDomainEnum.enumValues)[number];
+
diff --git a/db/seeds/menu-v2-seed.js b/db/seeds/menu-v2-seed.js
new file mode 100644
index 00000000..e332f044
--- /dev/null
+++ b/db/seeds/menu-v2-seed.js
@@ -0,0 +1,231 @@
+"use strict";
+var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
+    function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
+    return new (P || (P = Promise))(function (resolve, reject) {
+        function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
+        function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
+        function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
+        step((generator = generator.apply(thisArg, _arguments || [])).next());
+    });
+};
+var __generator = (this && this.__generator) || function (thisArg, body) {
+    var _ = { label: 0, sent: function() { if (t[0] & 1) throw t[1]; return t[1]; }, trys: [], ops: [] }, f, y, t, g = Object.create((typeof Iterator === "function" ? Iterator : Object).prototype);
+    return g.next = verb(0), g["throw"] = verb(1), g["return"] = verb(2), typeof Symbol === "function" && (g[Symbol.iterator] = function() { return this; }), g;
+    function verb(n) { return function (v) { return step([n, v]); }; }
+    function step(op) {
+        if (f) throw new TypeError("Generator is already executing.");
+        while (g && (g = 0, op[0] && (_ = 0)), _) try {
+            if (f = 1, y && (t = op[0] & 2 ? y["return"] : op[0] ? y["throw"] || ((t = y["return"]) && t.call(y), 0) : y.next) && !(t = t.call(y, op[1])).done) return t;
+            if (y = 0, t) op = [op[0] & 2, t.value];
+            switch (op[0]) {
+                case 0: case 1: t = op; break;
+                case 4: _.label++; return { value: op[1], done: false };
+                case 5: _.label++; y = op[1]; op = [0]; continue;
+                case 7: op = _.ops.pop(); _.trys.pop(); continue;
+                default:
+                    if (!(t = _.trys, t = t.length > 0 && t[t.length - 1]) && (op[0] === 6 || op[0] === 2)) { _ = 0; continue; }
+                    if (op[0] === 3 && (!t || (op[1] > t[0] && op[1] < t[3]))) { _.label = op[1]; break; }
+                    if (op[0] === 6 && _.label < t[1]) { _.label = t[1]; t = op; break; }
+                    if (t && _.label < t[2]) { _.label = t[2]; _.ops.push(op); break; }
+                    if (t[2]) _.ops.pop();
+                    _.trys.pop(); continue;
+            }
+            op = body.call(thisArg, _);
+        } catch (e) { op = [6, e]; y = 0; } finally { f = t = 0; }
+        if (op[0] & 5) throw op[1]; return { value: op[0] ? op[1] : void 0, done: true };
+    }
+};
+Object.defineProperty(exports, "__esModule", { value: true });
+exports.seedMenuTree = seedMenuTree;
+// db/seeds/menu-v2-seed.ts
+var menuConfig_1 = require("@/config/menuConfig");
+var menu_json_1 = require("@/i18n/locales/ko/menu.json");
+var menu_json_2 = require("@/i18n/locales/en/menu.json");
+var db_1 = require("@/db/db");
+var menu_v2_1 = require("@/db/schema/menu-v2");
+// 중첩 키로 번역 값 가져오기
+function getTranslation(key, locale) {
+    var translations = locale === 'ko' ? menu_json_1.default : menu_json_2.default;
+    var keys = key.split('.');
+    var value = translations;
+    for (var _i = 0, keys_1 = keys; _i < keys_1.length; _i++) {
+        var k = keys_1[_i];
+        if (typeof value === 'object' && value !== null) {
+            value = value[k];
+        }
+        else {
+            return key;
+        }
+        if (value === undefined)
+            return key;
+    }
+    return typeof value === 'string' ? value : key;
+}
+function seedMenuTree() {
+    return __awaiter(this, void 0, void 0, function () {
+        return __generator(this, function (_a) {
+            switch (_a.label) {
+                case 0:
+                    console.log('🌱 Starting menu tree seeding...');
+                    // 기존 데이터 삭제
+                    return [4 /*yield*/, db_1.default.delete(menu_v2_1.menuTreeNodes)];
+                case 1:
+                    // 기존 데이터 삭제
+                    _a.sent();
+                    console.log('✅ Cleared existing menu tree data');
+                    // evcp 도메인 seed
+                    return [4 /*yield*/, seedDomainMenus('evcp', menuConfig_1.mainNav, menuConfig_1.additionalNav)];
+                case 2:
+                    // evcp 도메인 seed
+                    _a.sent();
+                    console.log('✅ Seeded evcp menu tree');
+                    // partners 도메인 seed
+                    return [4 /*yield*/, seedDomainMenus('partners', menuConfig_1.mainNavVendor, menuConfig_1.additionalNavVendor)];
+                case 3:
+                    // partners 도메인 seed
+                    _a.sent();
+                    console.log('✅ Seeded partners menu tree');
+                    console.log('🎉 Menu tree seeding completed!');
+                    return [2 /*return*/];
+            }
+        });
+    });
+}
+function seedDomainMenus(domain, navConfig, additionalConfig) {
+    return __awaiter(this, void 0, void 0, function () {
+        var globalSortOrder, _loop_1, _i, navConfig_1, section, additionalSortOrder, _a, additionalConfig_1, item;
+        return __generator(this, function (_b) {
+            switch (_b.label) {
+                case 0:
+                    globalSortOrder = 0;
+                    _loop_1 = function (section) {
+                        var menuGroup, groupedItems, groupSortOrder, _c, groupedItems_1, _d, groupKey, items, parentId, group, menuSortOrder, _e, items_1, item;
+                        return __generator(this, function (_f) {
+                            switch (_f.label) {
+                                case 0: return [4 /*yield*/, db_1.default.insert(menu_v2_1.menuTreeNodes).values({
+                                        domain: domain,
+                                        parentId: null,
+                                        nodeType: 'menu_group',
+                                        titleKo: getTranslation(section.titleKey, 'ko'),
+                                        titleEn: getTranslation(section.titleKey, 'en'),
+                                        sortOrder: globalSortOrder++,
+                                        isActive: true,
+                                    }).returning()];
+                                case 1:
+                                    menuGroup = (_f.sent())[0];
+                                    groupedItems = new Map();
+                                    section.items.forEach(function (item) {
+                                        var groupKey = item.groupKey || '__default__';
+                                        if (!groupedItems.has(groupKey)) {
+                                            groupedItems.set(groupKey, []);
+                                        }
+                                        groupedItems.get(groupKey).push(item);
+                                    });
+                                    groupSortOrder = 0;
+                                    _c = 0, groupedItems_1 = groupedItems;
+                                    _f.label = 2;
+                                case 2:
+                                    if (!(_c < groupedItems_1.length)) return [3 /*break*/, 9];
+                                    _d = groupedItems_1[_c], groupKey = _d[0], items = _d[1];
+                                    parentId = menuGroup.id;
+                                    if (!(groupKey !== '__default__')) return [3 /*break*/, 4];
+                                    return [4 /*yield*/, db_1.default.insert(menu_v2_1.menuTreeNodes).values({
+                                            domain: domain,
+                                            parentId: menuGroup.id,
+                                            nodeType: 'group',
+                                            titleKo: getTranslation(groupKey, 'ko'),
+                                            titleEn: getTranslation(groupKey, 'en'),
+                                            sortOrder: groupSortOrder++,
+                                            isActive: true,
+                                        }).returning()];
+                                case 3:
+                                    group = (_f.sent())[0];
+                                    parentId = group.id;
+                                    _f.label = 4;
+                                case 4:
+                                    menuSortOrder = 0;
+                                    _e = 0, items_1 = items;
+                                    _f.label = 5;
+                                case 5:
+                                    if (!(_e < items_1.length)) return [3 /*break*/, 8];
+                                    item = items_1[_e];
+                                    return [4 /*yield*/, db_1.default.insert(menu_v2_1.menuTreeNodes).values({
+                                            domain: domain,
+                                            parentId: parentId,
+                                            nodeType: 'menu',
+                                            titleKo: getTranslation(item.titleKey, 'ko'),
+                                            titleEn: getTranslation(item.titleKey, 'en'),
+                                            descriptionKo: item.descriptionKey ? getTranslation(item.descriptionKey, 'ko') : null,
+                                            descriptionEn: item.descriptionKey ? getTranslation(item.descriptionKey, 'en') : null,
+                                            menuPath: item.href,
+                                            icon: item.icon || null,
+                                            sortOrder: menuSortOrder++,
+                                            isActive: true,
+                                        })];
+                                case 6:
+                                    _f.sent();
+                                    _f.label = 7;
+                                case 7:
+                                    _e++;
+                                    return [3 /*break*/, 5];
+                                case 8:
+                                    _c++;
+                                    return [3 /*break*/, 2];
+                                case 9: return [2 /*return*/];
+                            }
+                        });
+                    };
+                    _i = 0, navConfig_1 = navConfig;
+                    _b.label = 1;
+                case 1:
+                    if (!(_i < navConfig_1.length)) return [3 /*break*/, 4];
+                    section = navConfig_1[_i];
+                    return [5 /*yield**/, _loop_1(section)];
+                case 2:
+                    _b.sent();
+                    _b.label = 3;
+                case 3:
+                    _i++;
+                    return [3 /*break*/, 1];
+                case 4:
+                    additionalSortOrder = 0;
+                    _a = 0, additionalConfig_1 = additionalConfig;
+                    _b.label = 5;
+                case 5:
+                    if (!(_a < additionalConfig_1.length)) return [3 /*break*/, 8];
+                    item = additionalConfig_1[_a];
+                    return [4 /*yield*/, db_1.default.insert(menu_v2_1.menuTreeNodes).values({
+                            domain: domain,
+                            parentId: null,
+                            nodeType: 'additional',
+                            titleKo: getTranslation(item.titleKey, 'ko'),
+                            titleEn: getTranslation(item.titleKey, 'en'),
+                            descriptionKo: item.descriptionKey ? getTranslation(item.descriptionKey, 'ko') : null,
+                            descriptionEn: item.descriptionKey ? getTranslation(item.descriptionKey, 'en') : null,
+                            menuPath: item.href,
+                            sortOrder: additionalSortOrder++,
+                            isActive: true,
+                        })];
+                case 6:
+                    _b.sent();
+                    _b.label = 7;
+                case 7:
+                    _a++;
+                    return [3 /*break*/, 5];
+                case 8: return [2 /*return*/];
+            }
+        });
+    });
+}
+// CLI에서 직접 실행 가능하도록
+if (require.main === module) {
+    seedMenuTree()
+        .then(function () {
+        console.log('Seed completed successfully');
+        process.exit(0);
+    })
+        .catch(function (error) {
+        console.error('Seed failed:', error);
+        process.exit(1);
+    });
+}
diff --git a/db/seeds/menu-v2-seed.ts b/db/seeds/menu-v2-seed.ts
new file mode 100644
index 00000000..0c6b310d
--- /dev/null
+++ b/db/seeds/menu-v2-seed.ts
@@ -0,0 +1,145 @@
+// db/seeds/menu-v2-seed.ts
+import { mainNav, additionalNav, mainNavVendor, additionalNavVendor, MenuSection, MenuItem } from "@/config/menuConfig";
+import koMenu from '@/i18n/locales/ko/menu.json';
+import enMenu from '@/i18n/locales/en/menu.json';
+import db from "@/db/db";
+import { menuTreeNodes } from "@/db/schema/menu-v2";
+import type { MenuDomain } from "@/lib/menu-v2/types";
+
+type TranslationObject = { [key: string]: string | TranslationObject };
+
+// 중첩 키로 번역 값 가져오기
+function getTranslation(key: string, locale: 'ko' | 'en'): string {
+  const translations: TranslationObject = locale === 'ko' ? koMenu : enMenu;
+  const keys = key.split('.');
+  let value: string | TranslationObject | undefined = translations;
+  
+  for (const k of keys) {
+    if (typeof value === 'object' && value !== null) {
+      value = value[k];
+    } else {
+      return key;
+    }
+    if (value === undefined) return key;
+  }
+  
+  return typeof value === 'string' ? value : key;
+}
+
+export async function seedMenuTree() {
+  console.log('🌱 Starting menu tree seeding...');
+  
+  // 기존 데이터 삭제
+  await db.delete(menuTreeNodes);
+  console.log('✅ Cleared existing menu tree data');
+  
+  // evcp 도메인 seed
+  await seedDomainMenus('evcp', mainNav, additionalNav);
+  console.log('✅ Seeded evcp menu tree');
+  
+  // partners 도메인 seed
+  await seedDomainMenus('partners', mainNavVendor, additionalNavVendor);
+  console.log('✅ Seeded partners menu tree');
+  
+  console.log('🎉 Menu tree seeding completed!');
+}
+
+async function seedDomainMenus(
+  domain: MenuDomain, 
+  navConfig: MenuSection[],
+  additionalConfig: MenuItem[]
+) {
+  // 최상위 sortOrder (메뉴그룹과 최상위 메뉴 모두 같은 레벨에서 정렬)
+  let topLevelSortOrder = 0;
+  
+  // 메인 네비게이션 (메뉴그룹 → 그룹 → 메뉴)
+  for (const section of navConfig) {
+    // 1단계: 메뉴그룹 생성
+    const [menuGroup] = await db.insert(menuTreeNodes).values({
+      domain,
+      parentId: null,
+      nodeType: 'menu_group',
+      titleKo: getTranslation(section.titleKey, 'ko'),
+      titleEn: getTranslation(section.titleKey, 'en'),
+      sortOrder: topLevelSortOrder++,
+      isActive: true,
+    }).returning();
+    
+    // groupKey별로 그룹화
+    const groupedItems = new Map<string, MenuItem[]>();
+    section.items.forEach(item => {
+      const groupKey = item.groupKey || '__default__';
+      if (!groupedItems.has(groupKey)) {
+        groupedItems.set(groupKey, []);
+      }
+      groupedItems.get(groupKey)!.push(item);
+    });
+    
+    let groupSortOrder = 0;
+    for (const [groupKey, items] of groupedItems) {
+      let parentId = menuGroup.id;
+      
+      // groupKey가 있으면 2단계 그룹 생성
+      if (groupKey !== '__default__') {
+        const [group] = await db.insert(menuTreeNodes).values({
+          domain,
+          parentId: menuGroup.id,
+          nodeType: 'group',
+          titleKo: getTranslation(groupKey, 'ko'),
+          titleEn: getTranslation(groupKey, 'en'),
+          sortOrder: groupSortOrder++,
+          isActive: true,
+        }).returning();
+        parentId = group.id;
+      }
+      
+      // 3단계: 메뉴 생성
+      let menuSortOrder = 0;
+      for (const item of items) {
+        await db.insert(menuTreeNodes).values({
+          domain,
+          parentId,
+          nodeType: 'menu',
+          titleKo: getTranslation(item.titleKey, 'ko'),
+          titleEn: getTranslation(item.titleKey, 'en'),
+          descriptionKo: item.descriptionKey ? getTranslation(item.descriptionKey, 'ko') : null,
+          descriptionEn: item.descriptionKey ? getTranslation(item.descriptionKey, 'en') : null,
+          menuPath: item.href,
+          icon: item.icon || null,
+          sortOrder: menuSortOrder++,
+          isActive: true,
+        });
+      }
+    }
+  }
+  
+  // 최상위 단일 링크 메뉴 (기존 additional)
+  // nodeType을 'menu'로 설정하고 parentId를 null로 유지
+  for (const item of additionalConfig) {
+    await db.insert(menuTreeNodes).values({
+      domain,
+      parentId: null,
+      nodeType: 'menu', // 'additional' 대신 'menu' 사용
+      titleKo: getTranslation(item.titleKey, 'ko'),
+      titleEn: getTranslation(item.titleKey, 'en'),
+      descriptionKo: item.descriptionKey ? getTranslation(item.descriptionKey, 'ko') : null,
+      descriptionEn: item.descriptionKey ? getTranslation(item.descriptionKey, 'en') : null,
+      menuPath: item.href,
+      sortOrder: topLevelSortOrder++, // 메뉴그룹 다음 순서
+      isActive: true,
+    });
+  }
+}
+
+// CLI에서 직접 실행 가능하도록
+if (require.main === module) {
+  seedMenuTree()
+    .then(() => {
+      console.log('Seed completed successfully');
+      process.exit(0);
+    })
+    .catch((error) => {
+      console.error('Seed failed:', error);
+      process.exit(1);
+    });
+}
diff --git a/hooks/use-visible-menu-tree.ts b/hooks/use-visible-menu-tree.ts
new file mode 100644
index 00000000..bc7f1f73
--- /dev/null
+++ b/hooks/use-visible-menu-tree.ts
@@ -0,0 +1,49 @@
+"use client";
+
+import { useState, useEffect, useCallback } from "react";
+import { getVisibleMenuTree } from "@/lib/menu-v2/permission-service";
+import type { MenuDomain, MenuTreeNode, MenuTreeActiveResult } from "@/lib/menu-v2/types";
+
+interface UseVisibleMenuTreeResult extends MenuTreeActiveResult {
+  isLoading: boolean;
+  error: Error | null;
+  refetch: () => Promise<void>;
+}
+
+/**
+ * Hook to fetch user's visible menu tree (filtered by permissions)
+ * Tree contains both menu groups (dropdowns) and top-level menus (single links)
+ */
+export function useVisibleMenuTree(domain: MenuDomain): UseVisibleMenuTreeResult {
+  const [tree, setTree] = useState<MenuTreeNode[]>([]);
+  const [isLoading, setIsLoading] = useState(true);
+  const [error, setError] = useState<Error | null>(null);
+
+  const fetchMenuTree = useCallback(async () => {
+    setIsLoading(true);
+    setError(null);
+    
+    try {
+      // Call server action directly
+      const result = await getVisibleMenuTree(domain);
+      setTree(result.tree);
+    } catch (err) {
+      console.error("Error fetching visible menu tree:", err);
+      setError(err instanceof Error ? err : new Error("Unknown error"));
+      setTree([]);
+    } finally {
+      setIsLoading(false);
+    }
+  }, [domain]);
+
+  useEffect(() => {
+    fetchMenuTree();
+  }, [fetchMenuTree]);
+
+  return {
+    tree,
+    isLoading,
+    error,
+    refetch: fetchMenuTree,
+  };
+}
diff --git a/lib/information/service.ts b/lib/information/service.ts
index 02efe616..39e810e4 100644
--- a/lib/information/service.ts
+++ b/lib/information/service.ts
@@ -3,7 +3,7 @@
 import { getErrorMessage } from "@/lib/handle-error"
 import { desc, or, eq } from "drizzle-orm"
 import db from "@/db/db"
-import { pageInformation, menuAssignments, users } from "@/db/schema"
+import { pageInformation, menuTreeNodes, users } from "@/db/schema"
 import { saveDRMFile } from "@/lib/file-stroage"
 import { decryptWithServerAction } from "@/components/drm/drmUtils"
 
@@ -144,27 +144,27 @@ export async function checkInformationEditPermission(pagePath: string, userId: s
       pagePath // 원본 경로 정확한 매칭
     ]
     
-    // menu_assignments에서 해당 pagePath와 매칭되는 메뉴 찾기
-    const menuAssignment = await db
+    // menu_tree_nodes에서 해당 pagePath와 매칭되는 메뉴 찾기
+    const menuNode = await db
       .select()
-      .from(menuAssignments)
+      .from(menuTreeNodes)
       .where(
         or(
-          ...menuPathQueries.map(path => eq(menuAssignments.menuPath, path))
+          ...menuPathQueries.map(path => eq(menuTreeNodes.menuPath, path))
         )
       )
       .limit(1)
 
-    if (menuAssignment.length === 0) {
+    if (menuNode.length === 0) {
       // 매칭되는 메뉴가 없으면 권한 없음
       return false
     }
 
-    const assignment = menuAssignment[0]
+    const node = menuNode[0]
     const userIdNumber = parseInt(userId)
 
     // 현재 사용자가 manager1 또는 manager2인지 확인
-    return assignment.manager1Id === userIdNumber || assignment.manager2Id === userIdNumber
+    return node.manager1Id === userIdNumber || node.manager2Id === userIdNumber
   } catch (error) {
     console.error("Failed to check information edit permission:", error)
     return false
@@ -176,17 +176,21 @@ export async function getEditPermissionDirect(pagePath: string, userId: string)
   return await checkInformationEditPermission(pagePath, userId)
 }
 
-// menu_assignments 기반으로 page_information 동기화
+// menu_tree_nodes 기반으로 page_information 동기화
 export async function syncInformationFromMenuAssignments() {
   try {
-    // menu_assignments에서 모든 메뉴 가져오기
-    const menuItems = await db.select().from(menuAssignments);
+    // menu_tree_nodes에서 메뉴 타입 노드만 가져오기 (menuPath가 있는 것)
+    const menuItems = await db.select()
+      .from(menuTreeNodes)
+      .where(eq(menuTreeNodes.nodeType, 'menu'));
     
     let processedCount = 0;
     
     // upsert를 사용하여 각 메뉴 항목 처리
     for (const menu of menuItems) {
       try {
+        if (!menu.menuPath) continue;
+        
         // 맨 앞의 / 제거하여 pagePath 정규화
         const normalizedPagePath = menu.menuPath.startsWith('/')
           ? menu.menuPath.slice(1)
@@ -195,14 +199,14 @@ export async function syncInformationFromMenuAssignments() {
         await db.insert(pageInformation)
           .values({
             pagePath: normalizedPagePath,
-            pageName: menu.menuTitle,
+            pageName: menu.titleKo,
             informationContent: "",
             isActive: true // 기본값으로 활성화
           })
           .onConflictDoUpdate({
             target: pageInformation.pagePath,
             set: {
-              pageName: menu.menuTitle,
+              pageName: menu.titleKo,
               updatedAt: new Date()
             }
           });
@@ -213,8 +217,6 @@ export async function syncInformationFromMenuAssignments() {
       }
     }
     
-    // 캐시 무효화 제거됨
-    
     return {
       success: true,
       message: `페이지 정보 동기화 완료: ${processedCount}개 처리됨`
diff --git a/lib/menu-v2/components/add-node-dialog.tsx b/lib/menu-v2/components/add-node-dialog.tsx
new file mode 100644
index 00000000..b6762820
--- /dev/null
+++ b/lib/menu-v2/components/add-node-dialog.tsx
@@ -0,0 +1,186 @@
+"use client";
+
+import { useForm } from "react-hook-form";
+import {
+  Dialog,
+  DialogContent,
+  DialogDescription,
+  DialogFooter,
+  DialogHeader,
+  DialogTitle,
+} from "@/components/ui/dialog";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import type { 
+  MenuDomain, 
+  CreateMenuGroupInput, 
+  CreateGroupInput, 
+  CreateTopLevelMenuInput 
+} from "../types";
+
+type DialogType = "menu_group" | "group" | "top_level_menu";
+
+interface AddNodeDialogProps {
+  open: boolean;
+  onOpenChange: (open: boolean) => void;
+  type: DialogType;
+  domain: MenuDomain;
+  parentId?: number; // group 생성 시 필요
+  onSave: (data: CreateMenuGroupInput | CreateGroupInput | CreateTopLevelMenuInput) => Promise<void>;
+}
+
+interface FormData {
+  titleKo: string;
+  titleEn: string;
+  menuPath: string;
+}
+
+export function AddNodeDialog({
+  open,
+  onOpenChange,
+  type,
+  domain,
+  parentId,
+  onSave,
+}: AddNodeDialogProps) {
+  const {
+    register,
+    handleSubmit,
+    reset,
+    formState: { isSubmitting, errors },
+  } = useForm<FormData>({
+    defaultValues: {
+      titleKo: "",
+      titleEn: "",
+      menuPath: "",
+    },
+  });
+
+  const getTitle = () => {
+    switch (type) {
+      case "menu_group":
+        return "Add Menu Group";
+      case "group":
+        return "Add Group";
+      case "top_level_menu":
+        return "Add Top-Level Menu";
+      default:
+        return "Add";
+    }
+  };
+
+  const getDescription = () => {
+    switch (type) {
+      case "menu_group":
+        return "A dropdown trigger displayed in the header navigation.";
+      case "group":
+        return "Groups menus within a menu group.";
+      case "top_level_menu":
+        return "A single link displayed in the header navigation.";
+      default:
+        return "";
+    }
+  };
+
+  const onSubmit = async (data: FormData) => {
+    let saveData: CreateMenuGroupInput | CreateGroupInput | CreateTopLevelMenuInput;
+    
+    if (type === "menu_group") {
+      saveData = {
+        titleKo: data.titleKo,
+        titleEn: data.titleEn || undefined,
+      };
+    } else if (type === "group" && parentId) {
+      saveData = {
+        parentId,
+        titleKo: data.titleKo,
+        titleEn: data.titleEn || undefined,
+      };
+    } else if (type === "top_level_menu") {
+      saveData = {
+        titleKo: data.titleKo,
+        titleEn: data.titleEn || undefined,
+        menuPath: data.menuPath,
+      };
+    } else {
+      return;
+    }
+    
+    await onSave(saveData);
+    reset();
+    onOpenChange(false);
+  };
+
+  const handleClose = () => {
+    reset();
+    onOpenChange(false);
+  };
+
+  return (
+    <Dialog open={open} onOpenChange={handleClose}>
+      <DialogContent className="max-w-md">
+        <DialogHeader>
+          <DialogTitle>{getTitle()}</DialogTitle>
+          <DialogDescription>{getDescription()}</DialogDescription>
+        </DialogHeader>
+        
+        <form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
+          <div className="grid gap-4">
+            {/* Korean Name */}
+            <div className="grid gap-2">
+              <Label htmlFor="titleKo">Name (Korean) *</Label>
+              <Input
+                id="titleKo"
+                {...register("titleKo", { required: "Name is required" })}
+                placeholder="Master Data"
+              />
+              {errors.titleKo && (
+                <p className="text-xs text-destructive">{errors.titleKo.message}</p>
+              )}
+            </div>
+
+            {/* English Name */}
+            <div className="grid gap-2">
+              <Label htmlFor="titleEn">Name (English)</Label>
+              <Input
+                id="titleEn"
+                {...register("titleEn")}
+                placeholder="Master Data"
+              />
+            </div>
+
+            {/* Menu Path for Top-Level Menu */}
+            {type === "top_level_menu" && (
+              <div className="grid gap-2">
+                <Label htmlFor="menuPath">Menu Path *</Label>
+                <Input
+                  id="menuPath"
+                  {...register("menuPath", { 
+                    required: type === "top_level_menu" ? "Path is required" : false 
+                  })}
+                  placeholder={`/${domain}/dashboard`}
+                />
+                {errors.menuPath && (
+                  <p className="text-xs text-destructive">{errors.menuPath.message}</p>
+                )}
+                <p className="text-xs text-muted-foreground">
+                  e.g., /{domain}/report, /{domain}/faq
+                </p>
+              </div>
+            )}
+          </div>
+
+          <DialogFooter>
+            <Button type="button" variant="outline" onClick={handleClose}>
+              Cancel
+            </Button>
+            <Button type="submit" disabled={isSubmitting}>
+              {isSubmitting ? "Creating..." : "Create"}
+            </Button>
+          </DialogFooter>
+        </form>
+      </DialogContent>
+    </Dialog>
+  );
+}
diff --git a/lib/menu-v2/components/domain-tabs.tsx b/lib/menu-v2/components/domain-tabs.tsx
new file mode 100644
index 00000000..e52fa80b
--- /dev/null
+++ b/lib/menu-v2/components/domain-tabs.tsx
@@ -0,0 +1,25 @@
+"use client";
+
+import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
+import type { MenuDomain } from "../types";
+
+interface DomainTabsProps {
+  value: MenuDomain;
+  onChange: (domain: MenuDomain) => void;
+}
+
+export function DomainTabs({ value, onChange }: DomainTabsProps) {
+  return (
+    <Tabs value={value} onValueChange={(v) => onChange(v as MenuDomain)}>
+      <TabsList>
+        <TabsTrigger value="evcp">
+          EVCP (Internal)
+        </TabsTrigger>
+        <TabsTrigger value="partners">
+          Partners (Vendors)
+        </TabsTrigger>
+      </TabsList>
+    </Tabs>
+  );
+}
+
diff --git a/lib/menu-v2/components/edit-node-dialog.tsx b/lib/menu-v2/components/edit-node-dialog.tsx
new file mode 100644
index 00000000..9631a611
--- /dev/null
+++ b/lib/menu-v2/components/edit-node-dialog.tsx
@@ -0,0 +1,215 @@
+"use client";
+
+import { useEffect } from "react";
+import { useForm } from "react-hook-form";
+import {
+  Dialog,
+  DialogContent,
+  DialogDescription,
+  DialogFooter,
+  DialogHeader,
+  DialogTitle,
+} from "@/components/ui/dialog";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { Textarea } from "@/components/ui/textarea";
+import { Switch } from "@/components/ui/switch";
+import type { MenuTreeNode, UpdateNodeInput } from "../types";
+
+interface EditNodeDialogProps {
+  open: boolean;
+  onOpenChange: (open: boolean) => void;
+  node: MenuTreeNode | null;
+  onSave: (nodeId: number, data: UpdateNodeInput) => Promise<void>;
+}
+
+interface FormData {
+  titleKo: string;
+  titleEn: string;
+  descriptionKo: string;
+  descriptionEn: string;
+  scrId: string;
+  isActive: boolean;
+}
+
+export function EditNodeDialog({
+  open,
+  onOpenChange,
+  node,
+  onSave,
+}: EditNodeDialogProps) {
+  const {
+    register,
+    handleSubmit,
+    reset,
+    setValue,
+    watch,
+    formState: { isSubmitting },
+  } = useForm<FormData>({
+    defaultValues: {
+      titleKo: "",
+      titleEn: "",
+      descriptionKo: "",
+      descriptionEn: "",
+      scrId: "",
+      isActive: true,
+    },
+  });
+
+  const isActive = watch("isActive");
+
+  useEffect(() => {
+    if (node) {
+      reset({
+        titleKo: node.titleKo,
+        titleEn: node.titleEn || "",
+        descriptionKo: node.descriptionKo || "",
+        descriptionEn: node.descriptionEn || "",
+        scrId: node.scrId || "",
+        isActive: node.isActive,
+      });
+    }
+  }, [node, reset]);
+
+  const onSubmit = async (data: FormData) => {
+    if (!node) return;
+    
+    await onSave(node.id, {
+      titleKo: data.titleKo,
+      titleEn: data.titleEn || undefined,
+      descriptionKo: data.descriptionKo || undefined,
+      descriptionEn: data.descriptionEn || undefined,
+      scrId: data.scrId || undefined,
+      isActive: data.isActive,
+    });
+    
+    onOpenChange(false);
+  };
+
+  const getTypeLabel = () => {
+    switch (node?.nodeType) {
+      case "menu_group":
+        return "Menu Group";
+      case "group":
+        return "Group";
+      case "menu":
+        return "Menu";
+      case "additional":
+        return "Additional Menu";
+      default:
+        return "Node";
+    }
+  };
+
+  const showMenuFields = node?.nodeType === "menu" || node?.nodeType === "additional";
+
+  return (
+    <Dialog open={open} onOpenChange={onOpenChange}>
+      <DialogContent className="max-w-lg">
+        <DialogHeader>
+          <DialogTitle>Edit {getTypeLabel()}</DialogTitle>
+          <DialogDescription>
+            {node?.menuPath && (
+              <span className="text-xs text-muted-foreground">{node.menuPath}</span>
+            )}
+          </DialogDescription>
+        </DialogHeader>
+        
+        <form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
+          <div className="grid gap-4">
+            {/* Korean Name */}
+            <div className="grid gap-2">
+              <Label htmlFor="titleKo">Name (Korean) *</Label>
+              <Input
+                id="titleKo"
+                {...register("titleKo", { required: true })}
+                placeholder="Project List"
+              />
+            </div>
+
+            {/* English Name */}
+            <div className="grid gap-2">
+              <Label htmlFor="titleEn">Name (English)</Label>
+              <Input
+                id="titleEn"
+                {...register("titleEn")}
+                placeholder="Project List"
+              />
+            </div>
+
+            {/* Korean Description */}
+            {showMenuFields && (
+              <div className="grid gap-2">
+                <Label htmlFor="descriptionKo">Description (Korean)</Label>
+                <Textarea
+                  id="descriptionKo"
+                  {...register("descriptionKo")}
+                  placeholder="Project list from MDG (C)"
+                  rows={2}
+                />
+              </div>
+            )}
+
+            {/* English Description */}
+            {showMenuFields && (
+              <div className="grid gap-2">
+                <Label htmlFor="descriptionEn">Description (English)</Label>
+                <Textarea
+                  id="descriptionEn"
+                  {...register("descriptionEn")}
+                  placeholder="Project list from MDG (C)"
+                  rows={2}
+                />
+              </div>
+            )}
+
+            {/* Permission SCR_ID */}
+            {showMenuFields && (
+              <div className="grid gap-2">
+                <Label htmlFor="scrId">Permission SCR_ID (EVCP only)</Label>
+                <Input
+                  id="scrId"
+                  {...register("scrId")}
+                  placeholder="SCR_001"
+                />
+                <p className="text-xs text-muted-foreground">
+                  Linked with Oracle DB SCR_ID. If empty, auto-matched by URL.
+                </p>
+              </div>
+            )}
+
+            {/* Active Status */}
+            <div className="flex items-center justify-between">
+              <div className="space-y-0.5">
+                <Label htmlFor="isActive">Show in Menu</Label>
+                <p className="text-xs text-muted-foreground">
+                  When disabled, hidden from the navigation menu.
+                </p>
+              </div>
+              <Switch
+                id="isActive"
+                checked={isActive}
+                onCheckedChange={(checked) => setValue("isActive", checked)}
+              />
+            </div>
+          </div>
+
+          <DialogFooter>
+            <Button
+              type="button"
+              variant="outline"
+              onClick={() => onOpenChange(false)}
+            >
+              Cancel
+            </Button>
+            <Button type="submit" disabled={isSubmitting}>
+              {isSubmitting ? "Saving..." : "Save"}
+            </Button>
+          </DialogFooter>
+        </form>
+      </DialogContent>
+    </Dialog>
+  );
+}
+
diff --git a/lib/menu-v2/components/menu-tree-manager.tsx b/lib/menu-v2/components/menu-tree-manager.tsx
new file mode 100644
index 00000000..337eaee4
--- /dev/null
+++ b/lib/menu-v2/components/menu-tree-manager.tsx
@@ -0,0 +1,364 @@
+"use client";
+
+import { useState, useEffect, useCallback, useTransition } from "react";
+import { toast } from "sonner";
+import { Button } from "@/components/ui/button";
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
+import { RefreshCw, Plus, Loader2 } from "lucide-react";
+import { DomainTabs } from "./domain-tabs";
+import { MenuTree } from "./menu-tree";
+import { EditNodeDialog } from "./edit-node-dialog";
+import { AddNodeDialog } from "./add-node-dialog";
+import { MoveToDialog } from "./move-to-dialog";
+import { UnassignedMenusPanel } from "./unassigned-menus-panel";
+import {
+  getMenuTreeForAdmin,
+  createMenuGroup,
+  createGroup,
+  createTopLevelMenu,
+  updateNode,
+  moveNodeUp,
+  moveNodeDown,
+  moveNodeToParent,
+  getAvailableParents,
+  assignMenuToGroup,
+  activateAsTopLevelMenu,
+  syncDiscoveredMenus,
+} from "../service";
+import type { 
+  MenuDomain, 
+  MenuTreeNode, 
+  MenuTreeAdminResult, 
+  UpdateNodeInput,
+  CreateMenuGroupInput,
+  CreateGroupInput,
+  CreateTopLevelMenuInput,
+} from "../types";
+
+interface MenuTreeManagerProps {
+  initialDomain?: MenuDomain;
+}
+
+export function MenuTreeManager({ initialDomain = "evcp" }: MenuTreeManagerProps) {
+  const [domain, setDomain] = useState<MenuDomain>(initialDomain);
+  const [data, setData] = useState<MenuTreeAdminResult | null>(null);
+  const [isInitialLoading, setIsInitialLoading] = useState(true);
+  const [isPending, startTransition] = useTransition();
+  
+  // Dialog states
+  const [editDialogOpen, setEditDialogOpen] = useState(false);
+  const [editingNode, setEditingNode] = useState<MenuTreeNode | null>(null);
+  const [addDialogOpen, setAddDialogOpen] = useState(false);
+  const [addDialogType, setAddDialogType] = useState<"menu_group" | "group" | "top_level_menu">("menu_group");
+  const [addGroupParentId, setAddGroupParentId] = useState<number | undefined>(undefined);
+  
+  // Move dialog state
+  const [moveDialogOpen, setMoveDialogOpen] = useState(false);
+  const [movingNode, setMovingNode] = useState<MenuTreeNode | null>(null);
+  const [availableParents, setAvailableParents] = useState<{ id: number | null; title: string; depth: number }[]>([]);
+  
+  // Tree expansion state
+  const [expandedIds, setExpandedIds] = useState<Set<number>>(new Set());
+
+  // Load data using server action
+  const loadData = useCallback(async (isRefresh = false) => {
+    if (!isRefresh) {
+      setIsInitialLoading(true);
+    }
+    try {
+      const result = await getMenuTreeForAdmin(domain);
+      setData(result);
+    } catch (error) {
+      console.error("Error loading menu tree:", error);
+      toast.error("Failed to load menu tree");
+    } finally {
+      setIsInitialLoading(false);
+    }
+  }, [domain]);
+
+  useEffect(() => {
+    setExpandedIds(new Set());
+    loadData();
+  }, [loadData]);
+
+  const handleSync = async () => {
+    startTransition(async () => {
+      try {
+        const result = await syncDiscoveredMenus(domain);
+        toast.success(`Sync complete: ${result.added} menus added`);
+        loadData(true);
+      } catch (error) {
+        console.error("Error syncing menus:", error);
+        toast.error("Failed to sync menus");
+      }
+    });
+  };
+
+  const handleEdit = (node: MenuTreeNode) => {
+    setEditingNode(node);
+    setEditDialogOpen(true);
+  };
+
+  const handleSaveEdit = async (nodeId: number, input: UpdateNodeInput) => {
+    startTransition(async () => {
+      try {
+        await updateNode(nodeId, input);
+        toast.success("Saved successfully");
+        loadData(true);
+      } catch (error) {
+        console.error("Error updating node:", error);
+        toast.error("Failed to save");
+      }
+    });
+  };
+
+  // Move up (within same parent)
+  const handleMoveUp = async (nodeId: number) => {
+    startTransition(async () => {
+      try {
+        await moveNodeUp(nodeId);
+        loadData(true);
+      } catch (error) {
+        console.error("Error moving node up:", error);
+        toast.error("Failed to move");
+      }
+    });
+  };
+
+  // Move down (within same parent)
+  const handleMoveDown = async (nodeId: number) => {
+    startTransition(async () => {
+      try {
+        await moveNodeDown(nodeId);
+        loadData(true);
+      } catch (error) {
+        console.error("Error moving node down:", error);
+        toast.error("Failed to move");
+      }
+    });
+  };
+
+  // Open move to dialog
+  const handleOpenMoveDialog = async (node: MenuTreeNode) => {
+    setMovingNode(node);
+    try {
+      const parents = await getAvailableParents(node.id, domain, node.nodeType);
+      setAvailableParents(parents);
+      setMoveDialogOpen(true);
+    } catch (error) {
+      console.error("Error loading available parents:", error);
+      toast.error("Failed to load move options");
+    }
+  };
+
+  // Execute move to different parent
+  const handleMoveTo = async (newParentId: number | null) => {
+    if (!movingNode) return;
+    startTransition(async () => {
+      try {
+        await moveNodeToParent(movingNode.id, newParentId);
+        toast.success("Moved successfully");
+        setMoveDialogOpen(false);
+        setMovingNode(null);
+        loadData(true);
+      } catch (error) {
+        console.error("Error moving node:", error);
+        toast.error("Failed to move");
+      }
+    });
+  };
+
+  const handleAddMenuGroup = () => {
+    setAddDialogType("menu_group");
+    setAddGroupParentId(undefined);
+    setAddDialogOpen(true);
+  };
+
+  const handleAddGroup = (parentId: number) => {
+    setAddDialogType("group");
+    setAddGroupParentId(parentId);
+    setAddDialogOpen(true);
+  };
+
+  const handleAddTopLevelMenu = () => {
+    setAddDialogType("top_level_menu");
+    setAddGroupParentId(undefined);
+    setAddDialogOpen(true);
+  };
+
+  const handleSaveAdd = async (
+    input: CreateMenuGroupInput | CreateGroupInput | CreateTopLevelMenuInput
+  ) => {
+    startTransition(async () => {
+      try {
+        if (addDialogType === "menu_group") {
+          await createMenuGroup(domain, input as CreateMenuGroupInput);
+        } else if (addDialogType === "group") {
+          await createGroup(domain, input as CreateGroupInput);
+        } else if (addDialogType === "top_level_menu") {
+          await createTopLevelMenu(domain, input as CreateTopLevelMenuInput);
+        }
+        toast.success("Created successfully");
+        loadData(true);
+      } catch (error) {
+        console.error("Error creating node:", error);
+        toast.error("Failed to create");
+      }
+    });
+  };
+
+  const handleAssign = async (menuId: number, groupId: number) => {
+    startTransition(async () => {
+      try {
+        await assignMenuToGroup(menuId, groupId);
+        toast.success("Assigned successfully");
+        loadData(true);
+      } catch (error) {
+        console.error("Error assigning menu:", error);
+        toast.error("Failed to assign");
+      }
+    });
+  };
+
+  const handleActivateAsTopLevel = async (menuId: number) => {
+    startTransition(async () => {
+      try {
+        await activateAsTopLevelMenu(menuId);
+        toast.success("Activated as top-level menu");
+        loadData(true);
+      } catch (error) {
+        console.error("Error activating as top level:", error);
+        toast.error("Failed to activate");
+      }
+    });
+  };
+
+  // Build list of available groups for assignment
+  const getAvailableGroups = () => {
+    if (!data) return [];
+    
+    const groups: { id: number; title: string; parentTitle?: string }[] = [];
+    
+    for (const node of data.tree) {
+      if (node.nodeType !== 'menu_group') continue;
+      
+      groups.push({ id: node.id, title: node.titleKo });
+      
+      if (node.children) {
+        for (const child of node.children) {
+          if (child.nodeType === "group") {
+            groups.push({
+              id: child.id,
+              title: child.titleKo,
+              parentTitle: node.titleKo,
+            });
+          }
+        }
+      }
+    }
+    
+    return groups;
+  };
+
+  if (isInitialLoading) {
+    return (
+      <div className="flex items-center justify-center h-96">
+        <Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
+      </div>
+    );
+  }
+
+  return (
+    <div className="space-y-6">
+      {/* Header */}
+      <div className="flex items-center justify-between">
+        <DomainTabs value={domain} onChange={setDomain} />
+        <div className="flex items-center gap-2">
+          {/* [jh] I've commented this button.. */}
+          {/* <Button variant="outline" size="sm" onClick={handleSync} disabled={isPending}>
+            <RefreshCw className={`mr-2 h-4 w-4 ${isPending ? "animate-spin" : ""}`} />
+            Sync Pages
+          </Button> */}
+          <Button variant="outline" size="sm" onClick={handleAddTopLevelMenu} disabled={isPending}>
+            <Plus className="mr-2 h-4 w-4" />
+            Add Top-Level Menu
+          </Button>
+          <Button size="sm" onClick={handleAddMenuGroup} disabled={isPending}>
+            <Plus className="mr-2 h-4 w-4" />
+            Add Menu Group
+          </Button>
+        </div>
+      </div>
+
+      {/* Main Content */}
+      <div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
+        {/* Menu Tree */}
+        <div className="lg:col-span-2">
+          <Card>
+            <CardHeader>
+              <CardTitle>{domain === "evcp" ? "EVCP" : "Partners"} Menu Structure</CardTitle>
+              <CardDescription>
+                Use arrow buttons to reorder, or click Move To to change parent.
+              </CardDescription>
+            </CardHeader>
+            <CardContent>
+              {data?.tree && data.tree.length > 0 ? (
+                <MenuTree
+                  nodes={data.tree}
+                  onEdit={handleEdit}
+                  onMoveUp={handleMoveUp}
+                  onMoveDown={handleMoveDown}
+                  onMoveTo={handleOpenMoveDialog}
+                  onAddGroup={handleAddGroup}
+                  expandedIds={expandedIds}
+                  onExpandedIdsChange={setExpandedIds}
+                  isPending={isPending}
+                />
+              ) : (
+                <p className="text-sm text-muted-foreground text-center py-8">
+                  No menus. Add one using the buttons above.
+                </p>
+              )}
+            </CardContent>
+          </Card>
+        </div>
+
+        {/* Unassigned Menus */}
+        <div className="lg:col-span-1">
+          <UnassignedMenusPanel
+            menus={data?.unassigned || []}
+            onAssign={handleAssign}
+            onActivateAsTopLevel={handleActivateAsTopLevel}
+            onEdit={handleEdit}
+            availableGroups={getAvailableGroups()}
+          />
+        </div>
+      </div>
+
+      {/* Dialogs */}
+      <EditNodeDialog
+        open={editDialogOpen}
+        onOpenChange={setEditDialogOpen}
+        node={editingNode}
+        onSave={handleSaveEdit}
+      />
+
+      <AddNodeDialog
+        open={addDialogOpen}
+        onOpenChange={setAddDialogOpen}
+        type={addDialogType}
+        domain={domain}
+        parentId={addGroupParentId}
+        onSave={handleSaveAdd}
+      />
+
+      <MoveToDialog
+        open={moveDialogOpen}
+        onOpenChange={setMoveDialogOpen}
+        node={movingNode}
+        availableParents={availableParents}
+        onMove={handleMoveTo}
+      />
+    </div>
+  );
+}
diff --git a/lib/menu-v2/components/menu-tree.tsx b/lib/menu-v2/components/menu-tree.tsx
new file mode 100644
index 00000000..7d3ab077
--- /dev/null
+++ b/lib/menu-v2/components/menu-tree.tsx
@@ -0,0 +1,282 @@
+"use client";
+
+import { useCallback } from "react";
+import { cn } from "@/lib/utils";
+import { Button } from "@/components/ui/button";
+import { Badge } from "@/components/ui/badge";
+import {
+  ChevronRight,
+  ChevronDown,
+  ChevronUp,
+  Folder,
+  FolderOpen,
+  File,
+  Pencil,
+  Plus,
+  ArrowUpDown,
+  EyeOff,
+} from "lucide-react";
+import type { MenuTreeNode } from "../types";
+
+interface MenuTreeProps {
+  nodes: MenuTreeNode[];
+  onEdit: (node: MenuTreeNode) => void;
+  onMoveUp: (nodeId: number) => void;
+  onMoveDown: (nodeId: number) => void;
+  onMoveTo: (node: MenuTreeNode) => void;
+  onAddGroup: (parentId: number) => void;
+  expandedIds: Set<number>;
+  onExpandedIdsChange: (ids: Set<number>) => void;
+  isPending?: boolean;
+}
+
+interface TreeItemProps {
+  node: MenuTreeNode;
+  depth: number;
+  isFirst: boolean;
+  isLast: boolean;
+  onEdit: (node: MenuTreeNode) => void;
+  onMoveUp: (nodeId: number) => void;
+  onMoveDown: (nodeId: number) => void;
+  onMoveTo: (node: MenuTreeNode) => void;
+  onAddGroup: (parentId: number) => void;
+  isExpanded: boolean;
+  onToggleExpand: () => void;
+  isPending?: boolean;
+}
+
+function TreeItem({
+  node,
+  depth,
+  isFirst,
+  isLast,
+  onEdit,
+  onMoveUp,
+  onMoveDown,
+  onMoveTo,
+  onAddGroup,
+  isExpanded,
+  onToggleExpand,
+  isPending,
+}: TreeItemProps) {
+  const isMenuGroup = node.nodeType === "menu_group";
+  const isGroup = node.nodeType === "group";
+  const isMenu = node.nodeType === "menu";
+  const isTopLevel = node.parentId === null;
+  const hasChildren = node.children && node.children.length > 0;
+  const isExpandable = isMenuGroup || isGroup;
+  
+  // Move To is disabled for:
+  // - menu_group (always at top level, cannot be moved)
+  // - top-level menu (parentId === null, can only reorder with up/down)
+  const canMoveTo = !isMenuGroup && !isTopLevel;
+
+  const getIcon = () => {
+    if (isMenuGroup || isGroup) {
+      return isExpanded ? (
+        <FolderOpen className="h-4 w-4 text-amber-500" />
+      ) : (
+        <Folder className="h-4 w-4 text-amber-500" />
+      );
+    }
+    return <File className="h-4 w-4 text-slate-500" />;
+  };
+
+  const getTypeLabel = () => {
+    switch (node.nodeType) {
+      case "menu_group": return "Menu Group";
+      case "group": return "Group";
+      case "menu": return "Menu";
+      default: return "";
+    }
+  };
+
+  return (
+    <div
+      className={cn(
+        "flex items-center gap-2 px-2 py-1.5 rounded-md border bg-background hover:bg-accent/50 transition-colors",
+        !node.isActive && "opacity-50 bg-muted/30 border-dashed"
+      )}
+      style={{ marginLeft: depth * 24 }}
+    >
+      {/* Expand/Collapse */}
+      {isExpandable ? (
+        <button
+          onClick={onToggleExpand}
+          className="p-0.5 hover:bg-accent rounded shrink-0"
+        >
+          {isExpanded ? (
+            <ChevronDown className="h-4 w-4" />
+          ) : (
+            <ChevronRight className="h-4 w-4" />
+          )}
+        </button>
+      ) : (
+        <div className="w-5 shrink-0" />
+      )}
+
+      {/* Icon */}
+      {getIcon()}
+
+      {/* Title */}
+      <span className={cn(
+        "flex-1 text-sm font-medium truncate min-w-0",
+        !node.isActive && "line-through text-muted-foreground"
+      )}>
+        {node.titleKo}
+        {node.titleEn && (
+          <span className="text-muted-foreground font-normal"> [{node.titleEn}]</span>
+        )}
+      </span>
+
+      {/* Hidden indicator */}
+      {!node.isActive && (
+        <EyeOff className="h-3.5 w-3.5 text-muted-foreground shrink-0" title="Hidden" />
+      )}
+
+      {/* Path (for menus) */}
+      {isMenu && node.menuPath && (
+        <span className="text-xs text-muted-foreground truncate max-w-[150px] shrink-0">
+          {node.menuPath}
+        </span>
+      )}
+
+      {/* Type Badge */}
+      <Badge variant={node.isActive ? "default" : "secondary"} className="text-xs shrink-0">
+        {getTypeLabel()}
+      </Badge>
+
+      {/* Active indicator */}
+      <div
+        className={cn(
+          "w-2 h-2 rounded-full shrink-0",
+          node.isActive ? "bg-green-500" : "bg-gray-400"
+        )}
+        title={node.isActive ? "Visible" : "Hidden"}
+      />
+
+      {/* Actions */}
+      <div className="flex items-center gap-1 shrink-0">
+        {/* Move Up */}
+        <Button
+          variant="ghost"
+          size="icon"
+          className="h-7 w-7"
+          onClick={() => onMoveUp(node.id)}
+          disabled={isFirst || isPending}
+          title="Move Up"
+        >
+          <ChevronUp className="h-4 w-4" />
+        </Button>
+
+        {/* Move Down */}
+        <Button
+          variant="ghost"
+          size="icon"
+          className="h-7 w-7"
+          onClick={() => onMoveDown(node.id)}
+          disabled={isLast || isPending}
+          title="Move Down"
+        >
+          <ChevronDown className="h-4 w-4" />
+        </Button>
+
+        {/* Move To (different parent) - disabled for top level nodes */}
+        <Button
+          variant="ghost"
+          size="icon"
+          className="h-7 w-7"
+          onClick={() => onMoveTo(node)}
+          disabled={!canMoveTo || isPending}
+          title={canMoveTo ? "Move To..." : "Cannot move top-level items"}
+        >
+          <ArrowUpDown className="h-4 w-4" />
+        </Button>
+
+        {/* Edit */}
+        <Button
+          variant="ghost"
+          size="icon"
+          className="h-7 w-7"
+          onClick={() => onEdit(node)}
+          disabled={isPending}
+          title="Edit"
+        >
+          <Pencil className="h-4 w-4" />
+        </Button>
+
+        {/* Add Sub-Group (for menu groups only) */}
+        {isMenuGroup && (
+          <Button
+            variant="ghost"
+            size="icon"
+            className="h-7 w-7"
+            onClick={() => onAddGroup(node.id)}
+            disabled={isPending}
+            title="Add Sub-Group"
+          >
+            <Plus className="h-4 w-4" />
+          </Button>
+        )}
+      </div>
+    </div>
+  );
+}
+
+export function MenuTree({
+  nodes,
+  onEdit,
+  onMoveUp,
+  onMoveDown,
+  onMoveTo,
+  onAddGroup,
+  expandedIds,
+  onExpandedIdsChange,
+  isPending,
+}: MenuTreeProps) {
+  const toggleExpand = useCallback((nodeId: number) => {
+    const next = new Set(expandedIds);
+    if (next.has(nodeId)) {
+      next.delete(nodeId);
+    } else {
+      next.add(nodeId);
+    }
+    onExpandedIdsChange(next);
+  }, [expandedIds, onExpandedIdsChange]);
+
+  const renderTree = (nodeList: MenuTreeNode[], depth: number) => {
+    return nodeList.map((node, index) => {
+      const isExpanded = expandedIds.has(node.id);
+      const isExpandable = node.nodeType === "menu_group" || node.nodeType === "group";
+      const hasChildren = node.children && node.children.length > 0;
+
+      return (
+        <div key={node.id} className="space-y-1">
+          <TreeItem
+            node={node}
+            depth={depth}
+            isFirst={index === 0}
+            isLast={index === nodeList.length - 1}
+            onEdit={onEdit}
+            onMoveUp={onMoveUp}
+            onMoveDown={onMoveDown}
+            onMoveTo={onMoveTo}
+            onAddGroup={onAddGroup}
+            isExpanded={isExpanded}
+            onToggleExpand={() => toggleExpand(node.id)}
+            isPending={isPending}
+          />
+          {isExpandable && isExpanded && hasChildren && (
+            <div className="space-y-1">
+              {renderTree(node.children!, depth + 1)}
+            </div>
+          )}
+        </div>
+      );
+    });
+  };
+
+  return <div className="space-y-1">{renderTree(nodes, 0)}</div>;
+}
+
+
diff --git a/lib/menu-v2/components/move-to-dialog.tsx b/lib/menu-v2/components/move-to-dialog.tsx
new file mode 100644
index 00000000..7253708b
--- /dev/null
+++ b/lib/menu-v2/components/move-to-dialog.tsx
@@ -0,0 +1,87 @@
+"use client";
+
+import {
+  Dialog,
+  DialogContent,
+  DialogDescription,
+  DialogHeader,
+  DialogTitle,
+} from "@/components/ui/dialog";
+import { Button } from "@/components/ui/button";
+import { ScrollArea } from "@/components/ui/scroll-area";
+import { cn } from "@/lib/utils";
+import { Folder, FolderOpen, Home } from "lucide-react";
+import type { MenuTreeNode } from "../types";
+
+interface MoveToDialogProps {
+  open: boolean;
+  onOpenChange: (open: boolean) => void;
+  node: MenuTreeNode | null;
+  availableParents: { id: number | null; title: string; depth: number }[];
+  onMove: (newParentId: number | null) => void;
+}
+
+export function MoveToDialog({
+  open,
+  onOpenChange,
+  node,
+  availableParents,
+  onMove,
+}: MoveToDialogProps) {
+  if (!node) return null;
+
+  const isCurrent = (parentId: number | null) => node.parentId === parentId;
+
+  return (
+    <Dialog open={open} onOpenChange={onOpenChange}>
+      <DialogContent className="max-w-md">
+        <DialogHeader>
+          <DialogTitle>Move To</DialogTitle>
+          <DialogDescription>
+            Select a new location for &quot;{node.titleKo}&quot;
+          </DialogDescription>
+        </DialogHeader>
+        
+        <ScrollArea className="max-h-[400px]">
+          <div className="space-y-0.5 p-1">
+            {availableParents.map((parent) => (
+              <Button
+                key={parent.id ?? 'root'}
+                variant={isCurrent(parent.id) ? "secondary" : "ghost"}
+                className={cn(
+                  "w-full justify-start h-auto py-2 text-sm",
+                  parent.depth === 0 && "font-medium",
+                  parent.depth === 1 && "font-medium",
+                  parent.depth === 2 && "text-muted-foreground"
+                )}
+                style={{ paddingLeft: parent.depth * 20 + 8 }}
+                onClick={() => onMove(parent.id)}
+                disabled={isCurrent(parent.id)}
+              >
+                {parent.id === null ? (
+                  <Home className="mr-2 h-4 w-4 text-blue-500 shrink-0" />
+                ) : parent.depth === 1 ? (
+                  <FolderOpen className="mr-2 h-4 w-4 text-amber-500 shrink-0" />
+                ) : (
+                  <Folder className="mr-2 h-4 w-4 text-amber-400 shrink-0" />
+                )}
+                <span className="truncate">{parent.title}</span>
+                {isCurrent(parent.id) && (
+                  <span className="ml-auto text-xs text-muted-foreground shrink-0">(current)</span>
+                )}
+              </Button>
+            ))}
+          </div>
+        </ScrollArea>
+
+        <div className="flex justify-end">
+          <Button variant="outline" onClick={() => onOpenChange(false)}>
+            Cancel
+          </Button>
+        </div>
+      </DialogContent>
+    </Dialog>
+  );
+}
+
+
diff --git a/lib/menu-v2/components/unassigned-menus-panel.tsx b/lib/menu-v2/components/unassigned-menus-panel.tsx
new file mode 100644
index 00000000..2c914f2a
--- /dev/null
+++ b/lib/menu-v2/components/unassigned-menus-panel.tsx
@@ -0,0 +1,178 @@
+"use client";
+
+import { useState } from "react";
+import { cn } from "@/lib/utils";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Badge } from "@/components/ui/badge";
+import { ScrollArea } from "@/components/ui/scroll-area";
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
+import { Search, FileQuestion, ArrowRight, Pencil, Link } from "lucide-react";
+import type { MenuTreeNode } from "../types";
+
+interface UnassignedMenusPanelProps {
+  menus: MenuTreeNode[];
+  onAssign: (menuId: number, groupId: number) => void;
+  onActivateAsTopLevel: (menuId: number) => void;
+  onEdit: (menu: MenuTreeNode) => void;
+  availableGroups: { id: number; title: string; parentTitle?: string }[];
+}
+
+export function UnassignedMenusPanel({
+  menus,
+  onAssign,
+  onActivateAsTopLevel,
+  onEdit,
+  availableGroups,
+}: UnassignedMenusPanelProps) {
+  const [searchTerm, setSearchTerm] = useState("");
+  const [selectedMenu, setSelectedMenu] = useState<number | null>(null);
+
+  const filteredMenus = menus.filter(
+    (menu) =>
+      menu.titleKo.toLowerCase().includes(searchTerm.toLowerCase()) ||
+      menu.menuPath?.toLowerCase().includes(searchTerm.toLowerCase())
+  );
+
+  return (
+    <Card className="h-full">
+      <CardHeader className="pb-3">
+        <CardTitle className="text-base flex items-center gap-2">
+          <FileQuestion className="h-4 w-4" />
+          Unassigned Menus ({menus.length})
+        </CardTitle>
+        <CardDescription>
+          Assign to a group or activate as a top-level link.
+        </CardDescription>
+      </CardHeader>
+      <CardContent className="space-y-3">
+        {/* Search */}
+        <div className="relative">
+          <Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
+          <Input
+            placeholder="Search..."
+            value={searchTerm}
+            onChange={(e) => setSearchTerm(e.target.value)}
+            className="pl-8"
+          />
+        </div>
+
+        {/* Menu List */}
+        <ScrollArea className="h-[400px]">
+          <div className="space-y-2">
+            {filteredMenus.length === 0 ? (
+              <p className="text-sm text-muted-foreground text-center py-4">
+                {searchTerm ? "No results found." : "No unassigned menus."}
+              </p>
+            ) : (
+              filteredMenus.map((menu) => (
+                <div
+                  key={menu.id}
+                  className={cn(
+                    "p-3 rounded-md border bg-background hover:bg-accent/50 transition-colors",
+                    selectedMenu === menu.id && "ring-2 ring-primary"
+                  )}
+                >
+                  <div className="flex items-start justify-between gap-2">
+                    <div className="flex-1 min-w-0">
+                      <div className="flex items-center gap-2">
+                        <span className="font-medium text-sm">{menu.titleKo}</span>
+                        <Badge variant="secondary" className="text-xs">
+                          Inactive
+                        </Badge>
+                      </div>
+                      <p className="text-xs text-muted-foreground truncate mt-1">
+                        {menu.menuPath}
+                      </p>
+                    </div>
+                    <Button
+                      variant="ghost"
+                      size="icon"
+                      className="h-7 w-7 shrink-0"
+                      onClick={() => onEdit(menu)}
+                    >
+                      <Pencil className="h-3.5 w-3.5" />
+                    </Button>
+                  </div>
+
+                  {/* Group Selection (expanded) */}
+                  {selectedMenu === menu.id ? (
+                    <div className="mt-3 pt-3 border-t space-y-2">
+                      {/* Activate as Top-Level */}
+                      <div>
+                        <p className="text-xs text-muted-foreground mb-2">
+                          Activate as top-level link:
+                        </p>
+                        <Button
+                          variant="default"
+                          size="sm"
+                          className="text-xs h-7"
+                          onClick={() => {
+                            onActivateAsTopLevel(menu.id);
+                            setSelectedMenu(null);
+                          }}
+                        >
+                          <Link className="mr-1 h-3 w-3" />
+                          Activate as Top-Level
+                        </Button>
+                      </div>
+
+                      {/* Assign to Group */}
+                      {availableGroups.length > 0 && (
+                        <div>
+                          <p className="text-xs text-muted-foreground mb-2">
+                            Or assign to group:
+                          </p>
+                          <div className="flex flex-wrap gap-1">
+                            {availableGroups.map((group) => (
+                              <Button
+                                key={group.id}
+                                variant="outline"
+                                size="sm"
+                                className="text-xs h-7"
+                                onClick={() => {
+                                  onAssign(menu.id, group.id);
+                                  setSelectedMenu(null);
+                                }}
+                              >
+                                {group.parentTitle && (
+                                  <span className="text-muted-foreground mr-1">
+                                    {group.parentTitle} &gt;
+                                  </span>
+                                )}
+                                {group.title}
+                                <ArrowRight className="ml-1 h-3 w-3" />
+                              </Button>
+                            ))}
+                          </div>
+                        </div>
+                      )}
+                      
+                      <Button
+                        variant="ghost"
+                        size="sm"
+                        className="text-xs"
+                        onClick={() => setSelectedMenu(null)}
+                      >
+                        Cancel
+                      </Button>
+                    </div>
+                  ) : (
+                    <Button
+                      variant="ghost"
+                      size="sm"
+                      className="mt-2 text-xs w-full"
+                      onClick={() => setSelectedMenu(menu.id)}
+                    >
+                      Assign / Activate
+                    </Button>
+                  )}
+                </div>
+              ))
+            )}
+          </div>
+        </ScrollArea>
+      </CardContent>
+    </Card>
+  );
+}
diff --git a/lib/menu-v2/permission-service.ts b/lib/menu-v2/permission-service.ts
new file mode 100644
index 00000000..e495ba23
--- /dev/null
+++ b/lib/menu-v2/permission-service.ts
@@ -0,0 +1,186 @@
+'use server';
+
+import { getServerSession } from "next-auth";
+import { authOptions } from "@/app/api/auth/[...nextauth]/route";
+import { getAllScreens, getAuthsByScreenId, getUserRoles, type ScreenEvcp, type RoleRelEvcp } from "@/lib/nonsap/db";
+import { getActiveMenuTree } from "./service";
+import type { MenuDomain, MenuTreeNode, MenuTreeActiveResult } from "./types";
+import db from "@/db/db";
+import { users } from "@/db/schema/users";
+import { eq } from "drizzle-orm";
+
+/**
+ * Oracle 권한 체크 스킵 여부 확인
+ * SKIP_ORACLE_PERMISSION_CHECK=true인 경우 Oracle DB 권한 체크를 건너뜀
+ */
+function shouldSkipOraclePermissionCheck(): boolean {
+  return process.env.SKIP_ORACLE_PERMISSION_CHECK === 'true';
+}
+
+/**
+ * 사용자 ID로 employeeNumber 조회
+ */
+async function getEmployeeNumberByUserId(userId: number): Promise<string | null> {
+  const [user] = await db.select({ employeeNumber: users.employeeNumber })
+    .from(users)
+    .where(eq(users.id, userId))
+    .limit(1);
+  
+  return user?.employeeNumber || null;
+}
+
+/**
+ * Get menu tree filtered by user permissions
+ * 
+ * @param domain - Domain (evcp | partners)
+ * @param userId - Optional user ID. If not provided, gets from session.
+ * 
+ * Environment variable SKIP_ORACLE_PERMISSION_CHECK=true skips Oracle permission check
+ */
+export async function getVisibleMenuTree(
+  domain: MenuDomain, 
+  userId?: number
+): Promise<MenuTreeActiveResult> {
+  const { tree: menuTree } = await getActiveMenuTree(domain);
+  
+  // Partners domain uses its own permission system (not implemented)
+  if (domain === 'partners') {
+    return { tree: menuTree };
+  }
+  
+  // Skip Oracle permission check in development
+  if (shouldSkipOraclePermissionCheck()) {
+    return { tree: menuTree };
+  }
+  
+  // Get userId from session if not provided
+  let effectiveUserId = userId;
+  if (!effectiveUserId) {
+    const session = await getServerSession(authOptions);
+    effectiveUserId = session?.user?.id ? parseInt(session.user.id, 10) : undefined;
+  }
+  
+  if (!effectiveUserId) {
+    return { tree: menuTree };
+  }
+  
+  // Get employeeNumber from userId
+  const empNo = await getEmployeeNumberByUserId(effectiveUserId);
+  if (!empNo) {
+    return { tree: menuTree };
+  }
+  
+  let screens: ScreenEvcp[];
+  let userRoles: RoleRelEvcp[];
+  
+  try {
+    [screens, userRoles] = await Promise.all([
+      getAllScreens(),
+      getUserRoles(empNo)
+    ]);
+  } catch (error) {
+    // Oracle DB 연결 실패 시 전체 메뉴 반환 (에러로 인한 접근 차단 방지)
+    console.error('[menu-v2] Oracle permission check failed, returning all menus:', error);
+    return { tree: menuTree };
+  }
+  
+  const userRoleIds = new Set(userRoles.map(r => r.ROLE_ID));
+  const screenMap = new Map<string, ScreenEvcp>(screens.map(s => [s.SCR_URL, s]));
+  
+  // 메뉴 필터링 (최상위 menu, menu_group, group 모두 처리)
+  async function filterByPermission(nodes: MenuTreeNode[]): Promise<MenuTreeNode[]> {
+    const result: MenuTreeNode[] = [];
+    
+    for (const node of nodes) {
+      // 메뉴 노드 (최상위 단일 링크 또는 하위 메뉴)
+      if (node.nodeType === 'menu' && node.menuPath) {
+        const screen = screenMap.get(node.menuPath);
+        
+        // 화면 정보가 없거나 SCRT_CHK_YN === 'N' 이면 표시
+        if (!screen || screen.SCRT_CHK_YN === 'N') {
+          result.push(node);
+          continue;
+        }
+        
+        // SCRT_CHK_YN === 'Y' 이면 권한 체크
+        if (screen.SCRT_CHK_YN === 'Y') {
+          const scrIdToCheck = node.scrId || screen.SCR_ID;
+          const auths = await getAuthsByScreenId(scrIdToCheck);
+          
+          const hasAccess = auths.some(auth => {
+            if (auth.ACSR_GB_CD === 'U' && auth.ACSR_ID === empNo) return true;
+            if (auth.ACSR_GB_CD === 'R' && userRoleIds.has(auth.ACSR_ID)) return true;
+            return false;
+          });
+          
+          if (hasAccess) result.push(node);
+        }
+      } 
+      // 메뉴그룹 또는 그룹 (자식 필터링 후 자식이 있으면 포함)
+      else if (node.nodeType === 'menu_group' || node.nodeType === 'group') {
+        const filteredChildren = await filterByPermission(node.children || []);
+        if (filteredChildren.length > 0) {
+          result.push({ ...node, children: filteredChildren });
+        }
+      }
+    }
+    
+    return result;
+  }
+  
+  const filteredTree = await filterByPermission(menuTree);
+  
+  return { tree: filteredTree };
+}
+
+/**
+ * 특정 메뉴 경로에 대한 접근 권한 확인
+ * 
+ * 환경변수 SKIP_ORACLE_PERMISSION_CHECK=true인 경우 항상 true 반환
+ */
+export async function checkMenuAccess(
+  menuPath: string,
+  userId: number
+): Promise<boolean> {
+  // Oracle 권한 체크 스킵 설정된 경우
+  if (shouldSkipOraclePermissionCheck()) {
+    return true;
+  }
+  
+  const empNo = await getEmployeeNumberByUserId(userId);
+  if (!empNo) return false;
+  
+  try {
+    const screens = await getAllScreens();
+    const screen = screens.find(s => s.SCR_URL === menuPath);
+    
+    // 등록되지 않은 화면 또는 권한 체크가 필요 없는 화면
+    if (!screen || screen.SCRT_CHK_YN === 'N') {
+      return true;
+    }
+    
+    // 삭제된 화면
+    if (screen.DEL_YN === 'Y') {
+      return false;
+    }
+    
+    // 권한 체크
+    const [auths, userRoles] = await Promise.all([
+      getAuthsByScreenId(screen.SCR_ID),
+      getUserRoles(empNo)
+    ]);
+    
+    const userRoleIds = new Set(userRoles.map(r => r.ROLE_ID));
+    
+    return auths.some(auth => {
+      if (auth.ACSR_GB_CD === 'U' && auth.ACSR_ID === empNo) return true;
+      if (auth.ACSR_GB_CD === 'R' && userRoleIds.has(auth.ACSR_ID)) return true;
+      return false;
+    });
+  } catch (error) {
+    // Oracle DB 연결 실패 시 접근 허용 (에러로 인한 차단 방지)
+    console.error('[menu-v2] Oracle permission check failed for path:', menuPath, error);
+    return true;
+  }
+}
+
diff --git a/lib/menu-v2/service.ts b/lib/menu-v2/service.ts
new file mode 100644
index 00000000..39ca144a
--- /dev/null
+++ b/lib/menu-v2/service.ts
@@ -0,0 +1,605 @@
+'use server';
+
+import fs from 'fs';
+import path from 'path';
+import db from "@/db/db";
+import { menuTreeNodes } from "@/db/schema/menu-v2";
+import { eq, and, asc, inArray, isNull } from "drizzle-orm";
+import { revalidatePath } from "next/cache";
+import type { 
+  MenuDomain, 
+  MenuTreeNode, 
+  MenuTreeAdminResult, 
+  MenuTreeActiveResult,
+  CreateMenuGroupInput,
+  CreateGroupInput,
+  UpdateNodeInput,
+  ReorderNodeInput,
+  DiscoveredMenu
+} from "./types";
+import { DOMAIN_APP_PATHS } from "./types";
+
+// 도메인별 전체 트리 조회 (관리 화면용)
+export async function getMenuTreeForAdmin(domain: MenuDomain): Promise<MenuTreeAdminResult> {
+  const nodes = await db.select()
+    .from(menuTreeNodes)
+    .where(eq(menuTreeNodes.domain, domain))
+    .orderBy(asc(menuTreeNodes.sortOrder));
+  
+  // 트리에 포함될 노드들:
+  // - menu_group (최상위 드롭다운)
+  // - group (드롭다운 내 그룹)
+  // - 배정된 menu (parentId !== null)
+  // - 최상위 menu (parentId === null, isActive === true) - 단일 링크
+  const treeNodes = nodes.filter(n => 
+    n.nodeType === 'menu_group' || 
+    n.nodeType === 'group' || 
+    (n.nodeType === 'menu' && n.parentId !== null) ||
+    (n.nodeType === 'menu' && n.parentId === null && n.isActive)
+  ) as MenuTreeNode[];
+  
+  const tree = buildTree(treeNodes);
+  
+  // 미배정 메뉴 (parentId가 null이고 isActive가 false인 menu)
+  const unassigned = nodes.filter(n => 
+    n.nodeType === 'menu' && n.parentId === null && !n.isActive
+  ) as MenuTreeNode[];
+  
+  return { tree, unassigned };
+}
+
+// 도메인별 활성 트리 조회 (헤더용)
+export async function getActiveMenuTree(domain: MenuDomain): Promise<MenuTreeActiveResult> {
+  const nodes = await db.select()
+    .from(menuTreeNodes)
+    .where(and(
+      eq(menuTreeNodes.domain, domain),
+      eq(menuTreeNodes.isActive, true)
+    ))
+    .orderBy(asc(menuTreeNodes.sortOrder));
+  
+  // 트리에 포함될 노드들:
+  // - menu_group (최상위 드롭다운)
+  // - group (드롭다운 내 그룹)
+  // - 배정된 menu (parentId !== null)
+  // - 최상위 menu (parentId === null) - 단일 링크
+  const treeNodes = nodes.filter(n => 
+    n.nodeType === 'menu_group' || 
+    n.nodeType === 'group' || 
+    n.nodeType === 'menu'
+  ) as MenuTreeNode[];
+  
+  const tree = buildTree(treeNodes);
+  
+  return { tree };
+}
+
+// 메뉴그룹 생성 (드롭다운)
+export async function createMenuGroup(domain: MenuDomain, data: CreateMenuGroupInput) {
+  const [result] = await db.insert(menuTreeNodes).values({
+    domain,
+    parentId: null,
+    nodeType: 'menu_group',
+    titleKo: data.titleKo,
+    titleEn: data.titleEn,
+    sortOrder: data.sortOrder ?? 0,
+    isActive: true,
+  }).returning();
+  
+  revalidatePath('/evcp/menu-v2');
+  return result;
+}
+
+// 그룹 생성 (메뉴그룹 하위)
+export async function createGroup(domain: MenuDomain, data: CreateGroupInput) {
+  const [result] = await db.insert(menuTreeNodes).values({
+    domain,
+    parentId: data.parentId,
+    nodeType: 'group',
+    titleKo: data.titleKo,
+    titleEn: data.titleEn,
+    sortOrder: data.sortOrder ?? 0,
+    isActive: true,
+  }).returning();
+  
+  revalidatePath('/evcp/menu-v2');
+  return result;
+}
+
+// 최상위 메뉴 생성 (단일 링크 - 기존 additional 역할)
+export async function createTopLevelMenu(domain: MenuDomain, data: {
+  titleKo: string;
+  titleEn?: string;
+  menuPath: string;
+  sortOrder?: number;
+}) {
+  const [result] = await db.insert(menuTreeNodes).values({
+    domain,
+    parentId: null,
+    nodeType: 'menu',
+    titleKo: data.titleKo,
+    titleEn: data.titleEn,
+    menuPath: data.menuPath,
+    sortOrder: data.sortOrder ?? 0,
+    isActive: true,
+  }).returning();
+  
+  revalidatePath('/evcp/menu-v2');
+  return result;
+}
+
+// 노드 이동 (드래그앤드롭)
+export async function moveNode(nodeId: number, newParentId: number | null, newSortOrder: number) {
+  await db.update(menuTreeNodes)
+    .set({ 
+      parentId: newParentId, 
+      sortOrder: newSortOrder,
+      updatedAt: new Date()
+    })
+    .where(eq(menuTreeNodes.id, nodeId));
+  
+  revalidatePath('/evcp/menu-v2');
+}
+
+// 노드 수정
+export async function updateNode(nodeId: number, data: UpdateNodeInput) {
+  await db.update(menuTreeNodes)
+    .set({ ...data, updatedAt: new Date() })
+    .where(eq(menuTreeNodes.id, nodeId));
+  
+  revalidatePath('/evcp/menu-v2');
+}
+
+// 노드 삭제
+export async function deleteNode(nodeId: number) {
+  const [node] = await db.select().from(menuTreeNodes).where(eq(menuTreeNodes.id, nodeId)).limit(1);
+  
+  if (!node) return;
+  
+  if (node.nodeType === 'menu') {
+    // 최상위 메뉴(parentId === null)는 직접 삭제 가능
+    // 하위 메뉴(parentId !== null)는 미배정으로
+    if (node.parentId === null) {
+      await db.delete(menuTreeNodes).where(eq(menuTreeNodes.id, nodeId));
+    } else {
+      await db.update(menuTreeNodes)
+        .set({ parentId: null, isActive: false, updatedAt: new Date() })
+        .where(eq(menuTreeNodes.id, nodeId));
+    }
+  } else {
+    // 메뉴그룹/그룹 삭제 시, 하위 메뉴는 미배정으로
+    const children = await db.select({ id: menuTreeNodes.id, nodeType: menuTreeNodes.nodeType })
+      .from(menuTreeNodes)
+      .where(eq(menuTreeNodes.parentId, nodeId));
+    
+    for (const child of children) {
+      if (child.nodeType === 'menu') {
+        // 메뉴는 미배정으로
+        await db.update(menuTreeNodes)
+          .set({ parentId: null, isActive: false, updatedAt: new Date() })
+          .where(eq(menuTreeNodes.id, child.id));
+      } else if (child.nodeType === 'group') {
+        // 그룹의 하위 메뉴도 미배정으로
+        await db.update(menuTreeNodes)
+          .set({ parentId: null, isActive: false, updatedAt: new Date() })
+          .where(eq(menuTreeNodes.parentId, child.id));
+        
+        // 그룹 삭제
+        await db.delete(menuTreeNodes).where(eq(menuTreeNodes.id, child.id));
+      }
+    }
+    
+    // 본 노드 삭제
+    await db.delete(menuTreeNodes).where(eq(menuTreeNodes.id, nodeId));
+  }
+  
+  revalidatePath('/evcp/menu-v2');
+}
+
+// 순서 일괄 변경
+export async function reorderNodes(updates: ReorderNodeInput[]) {
+  for (const { id, sortOrder } of updates) {
+    await db.update(menuTreeNodes)
+      .set({ sortOrder, updatedAt: new Date() })
+      .where(eq(menuTreeNodes.id, id));
+  }
+  revalidatePath('/evcp/menu-v2');
+}
+
+// 미배정 메뉴를 특정 그룹에 배정
+export async function assignMenuToGroup(menuId: number, groupId: number) {
+  await db.update(menuTreeNodes)
+    .set({ 
+      parentId: groupId, 
+      isActive: true,
+      updatedAt: new Date() 
+    })
+    .where(eq(menuTreeNodes.id, menuId));
+  
+  revalidatePath('/evcp/menu-v2');
+}
+
+// 미배정 메뉴를 최상위 메뉴로 활성화
+export async function activateAsTopLevelMenu(menuId: number) {
+  await db.update(menuTreeNodes)
+    .set({ 
+      parentId: null, 
+      isActive: true,
+      updatedAt: new Date() 
+    })
+    .where(eq(menuTreeNodes.id, menuId));
+  
+  revalidatePath('/evcp/menu-v2');
+}
+
+// 단일 노드 조회
+export async function getNodeById(nodeId: number): Promise<MenuTreeNode | null> {
+  const [node] = await db.select()
+    .from(menuTreeNodes)
+    .where(eq(menuTreeNodes.id, nodeId))
+    .limit(1);
+  
+  return node as MenuTreeNode | null;
+}
+
+// Helper: Convert flat list to tree
+function buildTree(nodes: MenuTreeNode[]): MenuTreeNode[] {
+  const nodeMap = new Map<number, MenuTreeNode>();
+  const roots: MenuTreeNode[] = [];
+  
+  nodes.forEach(node => {
+    nodeMap.set(node.id, { ...node, children: [] });
+  });
+  
+  nodes.forEach(node => {
+    const current = nodeMap.get(node.id)!;
+    if (node.parentId === null) {
+      roots.push(current);
+    } else {
+      const parent = nodeMap.get(node.parentId);
+      if (parent) {
+        if (!parent.children) parent.children = [];
+        parent.children.push(current);
+      }
+    }
+  });
+  
+  const sortChildren = (nodes: MenuTreeNode[]) => {
+    nodes.sort((a, b) => a.sortOrder - b.sortOrder);
+    nodes.forEach(node => {
+      if (node.children?.length) {
+        sortChildren(node.children);
+      }
+    });
+  };
+  sortChildren(roots);
+  
+  return roots;
+}
+
+// ============================================
+// Menu Discovery & Sync (Server Actions)
+// ============================================
+
+const DYNAMIC_SEGMENT_PATTERN = /^\[.+\]$/;
+
+/**
+ * Discover pages from app router for a specific domain
+ */
+function discoverMenusFromAppRouter(domain: MenuDomain): DiscoveredMenu[] {
+  const { appDir, basePath } = DOMAIN_APP_PATHS[domain];
+  const menus: DiscoveredMenu[] = [];
+  
+  function scanDirectory(dir: string, currentPath: string[], routeGroup: string) {
+    const absoluteDir = path.resolve(process.cwd(), dir);
+    
+    if (!fs.existsSync(absoluteDir)) return;
+    
+    const entries = fs.readdirSync(absoluteDir, { withFileTypes: true });
+    
+    for (const entry of entries) {
+      const fullPath = path.join(absoluteDir, entry.name);
+      
+      if (entry.isDirectory()) {
+        if (entry.name.startsWith('(') && entry.name.endsWith(')')) {
+          scanDirectory(fullPath, currentPath, entry.name);
+        }
+        else if (DYNAMIC_SEGMENT_PATTERN.test(entry.name)) {
+          continue;
+        }
+        else {
+          scanDirectory(fullPath, [...currentPath, entry.name], routeGroup);
+        }
+      }
+      else if (entry.name === 'page.tsx') {
+        const menuPath = basePath + (currentPath.length > 0 ? '/' + currentPath.join('/') : '');
+        menus.push({
+          domain,
+          menuPath,
+          pageFilePath: fullPath,
+          routeGroup
+        });
+      }
+    }
+  }
+  
+  scanDirectory(appDir, [], '');
+  return menus;
+}
+
+/**
+ * Sync discovered menus for a specific domain
+ */
+export async function syncDiscoveredMenus(domain: MenuDomain): Promise<{ added: number; removed: number }> {
+  const discovered = discoverMenusFromAppRouter(domain);
+  
+  const existing = await db.select({ 
+    id: menuTreeNodes.id,
+    menuPath: menuTreeNodes.menuPath 
+  })
+    .from(menuTreeNodes)
+    .where(and(
+      eq(menuTreeNodes.domain, domain),
+      inArray(menuTreeNodes.nodeType, ['menu', 'additional'])
+    ));
+  
+  const existingPaths = new Set(existing.map(e => e.menuPath).filter(Boolean));
+  
+  const newMenus = discovered.filter(d => !existingPaths.has(d.menuPath));
+  let added = 0;
+  
+  for (const menu of newMenus) {
+    const pathSegments = menu.menuPath.split('/').filter(Boolean);
+    const lastSegment = pathSegments[pathSegments.length - 1] || 'unknown';
+    
+    await db.insert(menuTreeNodes).values({
+      domain,
+      parentId: null,
+      nodeType: 'menu',
+      sortOrder: 0,
+      titleKo: lastSegment,
+      titleEn: lastSegment,
+      menuPath: menu.menuPath,
+      isActive: false,
+    });
+    added++;
+  }
+  
+  revalidatePath('/evcp/menu-v2');
+  return { added, removed: 0 };
+}
+
+/**
+ * Sync all domains
+ */
+export async function syncAllDomains(): Promise<Record<MenuDomain, { added: number; removed: number }>> {
+  const [evcp, partners] = await Promise.all([
+    syncDiscoveredMenus('evcp'),
+    syncDiscoveredMenus('partners')
+  ]);
+  return { evcp, partners };
+}
+
+/**
+ * Get discovered menus without syncing
+ */
+export async function getDiscoveredMenus(): Promise<Record<MenuDomain, DiscoveredMenu[]>> {
+  return {
+    evcp: discoverMenusFromAppRouter('evcp'),
+    partners: discoverMenusFromAppRouter('partners')
+  };
+}
+
+// ============================================
+// Move Node Helpers
+// ============================================
+
+/**
+ * Move node up within same parent (decrease sort order)
+ */
+export async function moveNodeUp(nodeId: number): Promise<void> {
+  const [node] = await db.select()
+    .from(menuTreeNodes)
+    .where(eq(menuTreeNodes.id, nodeId))
+    .limit(1);
+  
+  if (!node) return;
+  
+  // Get siblings (nodes with same parent)
+  const siblings = await db.select()
+    .from(menuTreeNodes)
+    .where(node.parentId === null 
+      ? and(eq(menuTreeNodes.domain, node.domain), isNull(menuTreeNodes.parentId))
+      : eq(menuTreeNodes.parentId, node.parentId)
+    )
+    .orderBy(asc(menuTreeNodes.sortOrder));
+  
+  // Find current index
+  const currentIndex = siblings.findIndex(s => s.id === nodeId);
+  if (currentIndex <= 0) return; // Already at top
+  
+  // Swap sort orders with previous node
+  const prevNode = siblings[currentIndex - 1];
+  const prevSortOrder = prevNode.sortOrder;
+  const currentSortOrder = node.sortOrder;
+  
+  // If sort orders are the same, assign unique values
+  if (prevSortOrder === currentSortOrder) {
+    await db.update(menuTreeNodes)
+      .set({ sortOrder: currentIndex - 1, updatedAt: new Date() })
+      .where(eq(menuTreeNodes.id, nodeId));
+    await db.update(menuTreeNodes)
+      .set({ sortOrder: currentIndex, updatedAt: new Date() })
+      .where(eq(menuTreeNodes.id, prevNode.id));
+  } else {
+    await db.update(menuTreeNodes)
+      .set({ sortOrder: prevSortOrder, updatedAt: new Date() })
+      .where(eq(menuTreeNodes.id, nodeId));
+    await db.update(menuTreeNodes)
+      .set({ sortOrder: currentSortOrder, updatedAt: new Date() })
+      .where(eq(menuTreeNodes.id, prevNode.id));
+  }
+  
+  revalidatePath('/evcp/menu-v2');
+}
+
+/**
+ * Move node down within same parent (increase sort order)
+ */
+export async function moveNodeDown(nodeId: number): Promise<void> {
+  const [node] = await db.select()
+    .from(menuTreeNodes)
+    .where(eq(menuTreeNodes.id, nodeId))
+    .limit(1);
+  
+  if (!node) return;
+  
+  // Get siblings (nodes with same parent)
+  const siblings = await db.select()
+    .from(menuTreeNodes)
+    .where(node.parentId === null 
+      ? and(eq(menuTreeNodes.domain, node.domain), isNull(menuTreeNodes.parentId))
+      : eq(menuTreeNodes.parentId, node.parentId)
+    )
+    .orderBy(asc(menuTreeNodes.sortOrder));
+  
+  // Find current index
+  const currentIndex = siblings.findIndex(s => s.id === nodeId);
+  if (currentIndex >= siblings.length - 1) return; // Already at bottom
+  
+  // Swap sort orders with next node
+  const nextNode = siblings[currentIndex + 1];
+  const nextSortOrder = nextNode.sortOrder;
+  const currentSortOrder = node.sortOrder;
+  
+  // If sort orders are the same, assign unique values
+  if (nextSortOrder === currentSortOrder) {
+    await db.update(menuTreeNodes)
+      .set({ sortOrder: currentIndex + 1, updatedAt: new Date() })
+      .where(eq(menuTreeNodes.id, nodeId));
+    await db.update(menuTreeNodes)
+      .set({ sortOrder: currentIndex, updatedAt: new Date() })
+      .where(eq(menuTreeNodes.id, nextNode.id));
+  } else {
+    await db.update(menuTreeNodes)
+      .set({ sortOrder: nextSortOrder, updatedAt: new Date() })
+      .where(eq(menuTreeNodes.id, nodeId));
+    await db.update(menuTreeNodes)
+      .set({ sortOrder: currentSortOrder, updatedAt: new Date() })
+      .where(eq(menuTreeNodes.id, nextNode.id));
+  }
+  
+  revalidatePath('/evcp/menu-v2');
+}
+
+/**
+ * Move node to a different parent
+ */
+export async function moveNodeToParent(nodeId: number, newParentId: number | null): Promise<void> {
+  const [node] = await db.select()
+    .from(menuTreeNodes)
+    .where(eq(menuTreeNodes.id, nodeId))
+    .limit(1);
+  
+  if (!node) return;
+  
+  // Get max sort order in new parent
+  const siblings = await db.select({ sortOrder: menuTreeNodes.sortOrder })
+    .from(menuTreeNodes)
+    .where(newParentId === null 
+      ? and(eq(menuTreeNodes.domain, node.domain), isNull(menuTreeNodes.parentId))
+      : eq(menuTreeNodes.parentId, newParentId)
+    )
+    .orderBy(asc(menuTreeNodes.sortOrder));
+  
+  const maxSortOrder = siblings.length > 0 ? Math.max(...siblings.map(s => s.sortOrder)) + 1 : 0;
+  
+  await db.update(menuTreeNodes)
+    .set({ 
+      parentId: newParentId, 
+      sortOrder: maxSortOrder,
+      updatedAt: new Date() 
+    })
+    .where(eq(menuTreeNodes.id, nodeId));
+  
+  revalidatePath('/evcp/menu-v2');
+}
+
+/**
+ * Get all possible parent targets for a node (for Move To dialog)
+ * Returns items in tree order (same as Menu Structure display)
+ * 
+ * Rules:
+ * - menu_group: Cannot be moved (always at top level)
+ * - group: Can only move to menu_group (not to root or other groups)
+ * - menu: Can move to root, menu_group, or group
+ */
+export async function getAvailableParents(
+  nodeId: number, 
+  domain: MenuDomain, 
+  nodeType: string
+): Promise<{ id: number | null; title: string; depth: number }[]> {
+  const nodes = await db.select()
+    .from(menuTreeNodes)
+    .where(and(
+      eq(menuTreeNodes.domain, domain),
+      inArray(menuTreeNodes.nodeType, ['menu_group', 'group'])
+    ))
+    .orderBy(asc(menuTreeNodes.sortOrder));
+  
+  const result: { id: number | null; title: string; depth: number }[] = [];
+  
+  // For menu nodes, allow moving to root (as top-level menu)
+  if (nodeType === 'menu') {
+    result.push({ id: null, title: 'Top Level (Root)', depth: 0 });
+  }
+  
+  // Build tree structure
+  const nodeMap = new Map(nodes.map(n => [n.id, n]));
+  const menuGroups = nodes.filter(n => n.parentId === null && n.nodeType === 'menu_group');
+  
+  // Helper to check if node is descendant of nodeId (prevent circular reference)
+  const isDescendantOf = (checkNode: typeof nodes[0], ancestorId: number): boolean => {
+    let parent = checkNode.parentId;
+    while (parent !== null) {
+      if (parent === ancestorId) return true;
+      const parentNode = nodeMap.get(parent);
+      parent = parentNode?.parentId ?? null;
+    }
+    return false;
+  };
+  
+  // Traverse tree in order (menu_group -> its children groups)
+  for (const menuGroup of menuGroups) {
+    // Skip if it's the node being moved or its descendant
+    if (menuGroup.id === nodeId || isDescendantOf(menuGroup, nodeId)) continue;
+    
+    // Add menu_group
+    result.push({ 
+      id: menuGroup.id, 
+      title: menuGroup.titleKo, 
+      depth: 1 
+    });
+    
+    // For group nodes, only menu_groups are valid targets (skip children)
+    if (nodeType === 'group') continue;
+    
+    // Add children groups (sorted by sortOrder)
+    const childGroups = nodes
+      .filter(n => n.parentId === menuGroup.id && n.nodeType === 'group')
+      .sort((a, b) => a.sortOrder - b.sortOrder);
+    
+    for (const group of childGroups) {
+      // Skip if it's the node being moved or its descendant
+      if (group.id === nodeId || isDescendantOf(group, nodeId)) continue;
+      
+      result.push({ 
+        id: group.id, 
+        title: group.titleKo, 
+        depth: 2 
+      });
+    }
+  }
+  
+  return result;
+}
diff --git a/lib/menu-v2/types.ts b/lib/menu-v2/types.ts
new file mode 100644
index 00000000..1be8a4fe
--- /dev/null
+++ b/lib/menu-v2/types.ts
@@ -0,0 +1,103 @@
+// lib/menu-v2/types.ts
+
+export type NodeType = 'menu_group' | 'group' | 'menu' | 'additional';
+export type MenuDomain = 'evcp' | 'partners';
+
+export interface MenuTreeNode {
+  id: number;
+  domain: MenuDomain;
+  parentId: number | null;
+  nodeType: NodeType;
+  sortOrder: number;
+  titleKo: string;
+  titleEn: string | null;
+  descriptionKo: string | null;
+  descriptionEn: string | null;
+  menuPath: string | null;
+  icon: string | null;
+  scrId: string | null;
+  isActive: boolean;
+  manager1Id: number | null;
+  manager2Id: number | null;
+  createdAt: Date;
+  updatedAt: Date;
+  // 조회 시 추가되는 필드
+  children?: MenuTreeNode[];
+}
+
+export interface DiscoveredMenu {
+  domain: MenuDomain;
+  menuPath: string;
+  pageFilePath: string;
+  routeGroup: string;
+}
+
+// 도메인별 앱 라우터 경로 설정
+export const DOMAIN_APP_PATHS: Record<MenuDomain, { 
+  appDir: string; 
+  basePath: string;
+}> = {
+  evcp: { 
+    appDir: 'app/[lng]/evcp/(evcp)', 
+    basePath: '/evcp' 
+  },
+  partners: { 
+    appDir: 'app/[lng]/partners', 
+    basePath: '/partners' 
+  }
+};
+
+// 관리자용 트리 조회 결과 타입
+// tree: 메뉴그룹(드롭다운) + 최상위 메뉴(단일 링크) 통합
+export interface MenuTreeAdminResult {
+  tree: MenuTreeNode[];
+  unassigned: MenuTreeNode[];
+}
+
+// 헤더용 트리 조회 결과 타입
+// tree: 메뉴그룹(드롭다운) + 최상위 메뉴(단일 링크) 통합
+export interface MenuTreeActiveResult {
+  tree: MenuTreeNode[];
+}
+
+// 노드 생성 타입
+export interface CreateMenuGroupInput {
+  titleKo: string;
+  titleEn?: string;
+  sortOrder?: number;
+}
+
+export interface CreateGroupInput {
+  parentId: number;
+  titleKo: string;
+  titleEn?: string;
+  sortOrder?: number;
+}
+
+// 최상위 메뉴 생성 (단일 링크)
+export interface CreateTopLevelMenuInput {
+  titleKo: string;
+  titleEn?: string;
+  menuPath: string;
+  sortOrder?: number;
+}
+
+// 노드 업데이트 타입
+export interface UpdateNodeInput {
+  titleKo?: string;
+  titleEn?: string;
+  descriptionKo?: string;
+  descriptionEn?: string;
+  isActive?: boolean;
+  scrId?: string;
+  icon?: string;
+  manager1Id?: number | null;
+  manager2Id?: number | null;
+}
+
+// 순서 변경 타입
+export interface ReorderNodeInput {
+  id: number;
+  sortOrder: number;
+}
+
-- 
cgit v1.2.3

